package memorysearch

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

// recoverPending runs at Start before the goroutine launches. It inspects
// any pending journal left by a previous owner that crashed mid-reconcile
// and finishes or rolls back the work so the daemon serves consistent
// state. Recovery is idempotent — running it twice is the same as once.
//
// Crash points and recovery semantics:
//
//   - phase=scanning: Build had not allocated a generation. Nothing to
//     clean up. Clear the journal. The next Submit will start fresh.
//
//   - phase=building: Build may or may not have allocated a generation
//     directory before the crash — the journal records the build intent
//     but the candidate basename is empty because the allocator runs
//     inside Build. The candidate directory, if any, lacks
//     BUILD_COMPLETE and is swept by sweepOrphanGenerations. Clear the
//     journal.
//
//   - phase=publishing: Build completed and the candidate carries
//     BUILD_COMPLETE. Re-publish the candidate if its manifest's
//     SourceRevision matches the journal's TargetRevision; otherwise drop
//     it as stale. Clear the journal. The previous generation remains on
//     disk as rollback.
//
//   - phase=clearing: Publish succeeded; GC was interrupted. Finish GC
//     using the candidate as current and the journal's PreviousBasename
//     as rollback target. Clear the journal.
//
// After the journal-specific recovery, sweepOrphanGenerations runs to
// catch partial-build directories that no journal references — the
// "journal write itself crashed" case. Without this sweep, an orphan
// from a build that crashed between allocation and the building-phase
// journal write would accumulate forever.
//
// In all cases, the previous current pointer stays serviceable until a
// new one is swapped in. The spec §5.2 invariant: a crash during an
// incremental update must leave either a previously committed manifest
// matching the indexed revision, or no claim of readiness.
func (r *Reconciler) recoverPending(ctx context.Context) error {
	p, err := loadPending(r.derivativeRoot)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			// No journal, but there may still be orphan partial dirs
			// from a build that crashed before its first journal write.
			return r.sweepOrphanGenerations(ctx, nil)
		}
		return err
	}

	// Refuse to act on a journal from a different binding unless a durable
	// lifecycle sidecar proves that the journal's exact binding was already
	// fenced. A completed A -> B transition deliberately abandons any partial
	// A pass; the lifecycle write precedes journal removal, so a crash in that
	// narrow window leaves this proof for B's startup recovery. Without the
	// exact fingerprint + non-active proof, clearing blindly could destroy a
	// newer binding's work and remains a fatal operator-visible error.
	if p.BindingFingerprint != r.opts.Binding.Fingerprint() {
		retired, proofErr := retiredPendingBindingProven(
			r.derivativeRoot, p.BindingFingerprint)
		if !retired {
			if proofErr != nil {
				return fmt.Errorf(
					"%w: pending journal fingerprint %q does not match current binding %q (retirement proof: %v)",
					ErrRecoveryBindingMismatch, p.BindingFingerprint,
					r.opts.Binding.Fingerprint(), proofErr)
			}
			return fmt.Errorf("%w: pending journal fingerprint %q does not match current binding %q",
				ErrRecoveryBindingMismatch, p.BindingFingerprint, r.opts.Binding.Fingerprint())
		}
		r.logger.InfoContext(ctx,
			"memory search: discarding journal for durably retired binding",
			"pending_binding", p.BindingFingerprint,
			"current_binding", r.opts.Binding.Fingerprint())
		if err := clearPending(r.derivativeRoot); err != nil {
			return fmt.Errorf("clear retired-binding pending: %w", err)
		}
		if err := r.sweepOrphanGenerations(ctx, nil); err != nil {
			return fmt.Errorf("sweep orphans (retired binding): %w", err)
		}
		return nil
	}

	// Basename validation: a forged or corrupted journal must never
	// reach the RemoveAll / Open paths below. validateBasename runs
	// inside writePendingAtomic on the write side, but recovery must
	// defend against journals written by an older binary, hand-edits,
	// or partial flushes.
	if err := validateBasename(p.CandidateBasename); err != nil {
		return fmt.Errorf("%w: pending journal candidate basename: %v",
			ErrRecoveryInvalidJournal, err)
	}
	if err := validateBasename(p.PreviousBasename); err != nil {
		return fmt.Errorf("%w: pending journal previous basename: %v",
			ErrRecoveryInvalidJournal, err)
	}

	r.logger.InfoContext(ctx, "memory search: recovering pending journal",
		"phase", p.Phase,
		"candidate", p.CandidateBasename,
		"target_revision", p.TargetRevision,
		"previous", p.PreviousBasename)

	switch p.Phase {
	case PendingPhaseScanning:
		// Nothing was built; just clear. Sweep orphans in case a
		// previous build's allocation raced the journal write.
		if err := r.sweepOrphanGenerations(ctx, p); err != nil {
			return fmt.Errorf("sweep orphans (scanning): %w", err)
		}
		return clearPending(r.derivativeRoot)

	case PendingPhaseBuilding:
		// The journal may or may not name the partial candidate
		// depending on whether Build had returned when the crash hit.
		// Drop the named candidate explicitly (it lacks BUILD_COMPLETE
		// by definition of building phase); then sweep any unnamed
		// orphans from prior crashed builds.
		if p.CandidateBasename != "" {
			if err := r.trashGeneration(p.CandidateBasename); err != nil && !errors.Is(err, os.ErrNotExist) {
				return fmt.Errorf("drop partial candidate %s: %w", p.CandidateBasename, err)
			}
		}
		if err := r.sweepOrphanGenerations(ctx, p); err != nil {
			return fmt.Errorf("sweep orphans (building): %w", err)
		}
		return clearPending(r.derivativeRoot)

	case PendingPhasePublishing:
		if err := r.recoverPublishing(ctx, p); err != nil {
			return err
		}
		if err := r.sweepOrphanGenerations(ctx, p); err != nil {
			return fmt.Errorf("sweep orphans (publishing): %w", err)
		}
		return clearPending(r.derivativeRoot)

	case PendingPhaseClearing:
		// Publish already succeeded; finish GC.
		if err := r.gcOlderGenerations(ctx, p.CandidateBasename, p.PreviousBasename); err != nil {
			return fmt.Errorf("finish clearing: %w", err)
		}
		if err := r.sweepOrphanGenerations(ctx, p); err != nil {
			return fmt.Errorf("sweep orphans (clearing): %w", err)
		}
		return clearPending(r.derivativeRoot)

	default:
		// Unknown phase: refuse to guess. Leave journal in place for
		// the operator.
		return fmt.Errorf("%w: unknown phase %q", ErrRecoveryUnknownPhase, p.Phase)
	}
}

// retiredPendingBindingProven validates lifecycle.json without assuming the
// current Reconciler's fingerprint. It returns true only when the sidecar is
// structurally valid, names the exact pending-journal fingerprint, and records
// a non-active binding state. That three-part proof is what makes it safe for a
// replacement binding to discard a crash-left journal from its predecessor.
//
// In particular, a stale A recovery must not delete a pending B journal merely
// because lifecycle.json still mentions A: lifecycle fingerprint and pending
// fingerprint must agree. Active, missing, corrupt, or unrelated lifecycle
// state all fail closed.
func retiredPendingBindingProven(derivativeRoot, pendingFingerprint string) (bool, error) {
	raw, err := os.ReadFile(filepath.Join(derivativeRoot, lifecycleFileName))
	if err != nil {
		return false, err
	}
	var state LifecycleState
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&state); err != nil {
		return false, fmt.Errorf("%w: decode retirement proof: %v", ErrLifecycleCorrupt, err)
	}
	if state.BindingFingerprint == "" {
		return false, fmt.Errorf("%w: empty retirement fingerprint", ErrLifecycleCorrupt)
	}
	if err := validateLifecycleState(&state, state.BindingFingerprint); err != nil {
		return false, err
	}
	if state.BindingFingerprint != pendingFingerprint {
		return false, nil
	}
	return state.BindingState != BindingLifecycleActive, nil
}

// recoverPublishing handles the phase=publishing case. The candidate
// directory must have BUILD_COMPLETE; the candidate manifest's
// SourceRevision must equal the journal's TargetRevision (no drift
// before crash); only then is the pointer swap completed.
func (r *Reconciler) recoverPublishing(ctx context.Context, p *Pending) error {
	if p.CandidateBasename == "" {
		// Journal was malformed (publishing without candidate); clear.
		return nil
	}
	// Re-validate: the journal was loaded from disk, treat it as
	// untrusted. (validateBasename already ran in recoverPending, but
	// this method may be called from tests that bypass that path.)
	if err := validateBasename(p.CandidateBasename); err != nil {
		return fmt.Errorf("%w: candidate basename: %v", ErrRecoveryInvalidJournal, err)
	}
	candidateDir := filepath.Join(r.generationsDir, p.CandidateBasename)

	// Marker gate.
	if _, err := os.Stat(filepath.Join(candidateDir, buildCompleteMarker)); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			// Build wrote the journal but not the marker: build crashed
			// between journal write and marker write. Drop candidate.
			if err := r.trashGeneration(p.CandidateBasename); err != nil {
				return fmt.Errorf("drop unmarked candidate %s: %w", p.CandidateBasename, err)
			}
			return nil
		}
		return err
	}

	// Load manifest to verify revision.
	manifestPath := filepath.Join(candidateDir, manifestFilename)
	manifest, err := LoadManifest(ctx, manifestPath, r.opts.Resolver)
	if err != nil {
		// Manifest corrupt: cannot trust candidate. Drop and clear.
		r.logger.WarnContext(ctx, "memory search: candidate manifest unreadable; dropping",
			"candidate", p.CandidateBasename, "error", err)
		if err := r.trashGeneration(p.CandidateBasename); err != nil {
			return fmt.Errorf("drop corrupt candidate %s: %w", p.CandidateBasename, err)
		}
		return nil
	}
	if manifest.SourceRevision != p.TargetRevision {
		// Stale: journal says one revision, candidate says another.
		// Drop and clear; next reconcile will build fresh.
		r.logger.WarnContext(ctx, "memory search: candidate revision drifts from journal; dropping",
			"candidate", p.CandidateBasename,
			"journal_target", p.TargetRevision,
			"manifest", manifest.SourceRevision)
		if err := r.trashGeneration(p.CandidateBasename); err != nil {
			return fmt.Errorf("drop stale candidate %s: %w", p.CandidateBasename, err)
		}
		return nil
	}

	// Re-publish. Idempotent if already published.
	c := Current{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		GenerationBasename: p.CandidateBasename,
		SourceRevision:     manifest.SourceRevision,
		PublishedAt:        r.clock(),
	}
	r.namespaceMu.Lock()
	err = writeCurrentAtomic(r.derivativeRoot, c)
	r.namespaceMu.Unlock()
	if err != nil {
		return fmt.Errorf("re-publish: %w", err)
	}
	if err := r.lifecycle.recordPublish(c, p.PreviousBasename); err != nil {
		return fmt.Errorf("record lifecycle after re-publish: %w", err)
	}

	// Finish GC the journal was about to do.
	if err := r.gcOlderGenerations(ctx, p.CandidateBasename, p.PreviousBasename); err != nil {
		return fmt.Errorf("finish clearing after re-publish: %w", err)
	}
	return nil
}

// ErrRecoveryBindingMismatch, ErrRecoveryUnknownPhase, and
// ErrRecoveryInvalidJournal are fatal recovery errors. The operator
// must inspect the journal and the current binding before retrying
// Start.
var (
	ErrRecoveryBindingMismatch = errors.New("memory search: pending journal binding mismatch")
	ErrRecoveryUnknownPhase    = errors.New("memory search: pending journal unknown phase")
	ErrRecoveryInvalidJournal  = errors.New("memory search: pending journal invalid basename")
)
