package diff

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/apex/log"
	"github.com/blacktop/ipsw/internal/diff/storage"
	"github.com/blacktop/ipsw/internal/search"
	"github.com/blacktop/ipsw/internal/utils"
	"github.com/blacktop/ipsw/pkg/info"
)

// runIPSWVolumePhases drives the volume-major loop for IPSW mode. See
// runVolumeJobsAcrossSessions for the per-volume orchestration logic; this
// method is the entry point that wires in the concrete Diff state.
//
// When d.store is non-nil (the CLI path that wires up --cache-dir /
// --no-cache) the orchestrator reuses that store so every task in the run
// shares one backend. When nil, a MemoryStore is allocated for the duration
// of the call and closed after finalize; that preserves the test/OTA paths
// that never need cross-run persistence.
func (d *Diff) runIPSWVolumePhases(jobs []Task) error {
	store, cleanup := d.acquireStore()
	if cleanup != nil {
		defer cleanup()
	}
	return runVolumeJobsAcrossSessions(
		d.Old.Info,
		d.New.Info,
		d.oldSession,
		d.newSession,
		func(typ string) bool { return d.ipswVolumeUnchanged(typ) },
		jobs,
		store,
	)
}

// acquireStore returns the cache backend the orchestrator should use, plus
// an optional cleanup hook. When the CLI installed a shared store via
// SetStore the cleanup is a no-op so the same store survives across the
// volume-major and top-level passes; otherwise an ephemeral MemoryStore is
// allocated and closed on return.
func (d *Diff) acquireStore() (storage.Store, func()) {
	if d.store != nil {
		return d.store, nil
	}
	store := storage.NewMemoryStore()
	return store, func() {
		if err := store.Close(); err != nil {
			log.WithError(err).Warn("failed to close diff store")
		}
	}
}

// runVolumeJobsAcrossSessions executes the volume-major loop. It is the
// pure orchestration helper that runIPSWVolumePhases delegates to; tests
// pass fake sessions and a fake "is volume unchanged" oracle to exercise
// the loop without real mounts.
//
// For each volume type in ipswVolumeOrderMachos it:
//
//  1. Filters jobs to those that need the volume.
//  2. Skips when no job wants it or neither side resolves it.
//  3. Skips when the per-volume short-circuit reports the digests match.
//  4. Mounts both sides, fans MachoWalkTasks out via a single shared
//     [search.ForEachMachoInMountMulti] per side, then runs the
//     remaining MountTasks sequentially.
//  5. Releases the volume pair (release fires even on scan error).
//
// After the loop, Finalize is called on every job (whether it processed any
// volume or not). Errors are logged per-stage and execution continues so a
// failure in one job does not poison the others.
func runVolumeJobsAcrossSessions(
	oldInfo, newInfo *info.Info,
	oldSession, newSession ipswVolumeFileSession,
	volumeUnchanged func(typ string) bool,
	jobs []Task,
	store storage.Store,
) error {
	if len(jobs) == 0 {
		return nil
	}

	// A task whose Setup fails is dropped for the whole run: it never sees a
	// volume and is not finalized. Running a half-initialized task produces
	// garbage (e.g. machosJob with an empty cacheDir writes its per-binary
	// cache into a relative path in the CWD; filesJob emits partial output).
	jobs = setupTasks(jobs, store)

	// Cache lifecycle: resolve each CacheableTask's scope, hydrate the ones
	// whose exact (task, version, options, inputs) tuple is already complete
	// in the store, and track per-task errors so a task that fails any step
	// is never marked complete. Hydrated tasks skip the volume walk entirely.
	lc := newCacheLifecycle(oldInfo, newInfo, jobs, store)

	for _, typ := range ipswVolumeOrderMachos {
		active := filterJobsByVolume(jobs, typ)
		active = lc.excludeHydrated(active)
		if len(active) == 0 {
			continue
		}

		oldPresent := volumeResolves(oldInfo, typ)
		newPresent := volumeResolves(newInfo, typ)
		if !oldPresent && !newPresent {
			continue
		}

		if volumeUnchanged != nil && volumeUnchanged(typ) {
			utils.Indent(log.Info, 2)(fmt.Sprintf("Skipping %s volume (DMG digest unchanged)", volumeLabel(typ)))
			continue
		}

		errs, errored := processVolumePair(typ, oldPresent, newPresent, oldSession, newSession, active)
		lc.markErrored(errored)
		if errs != nil {
			log.WithError(errs).Errorf("failed to process %s volume", volumeLabel(typ))
		}
	}

	// Finalize runs for EVERY task, hydrated or not. A freshly-walked task
	// folds its per-volume scan state here; a hydrated task takes its
	// Finalize hydrate-branch and publishes the cached result to the Diff
	// (e.g. machosJob.Finalize sets j.d.Machos = j.hydrated). Skipping
	// Finalize for hydrated tasks would leave their Diff field at the zero
	// value, serving empty output on every cache hit.
	for _, job := range jobs {
		if err := finalizeTask(job); err != nil {
			lc.markErrored(map[string]bool{job.Name(): true})
			log.WithError(err).Errorf("failed to finalize %s job", job.Name())
		}
	}

	// Persist freshly-walked cacheable tasks and stamp their completion
	// sentinel. Hydrated tasks already have their rows and completion sentinel
	// in the store, so persistAndComplete skips them; tasks with any recorded
	// error are skipped so a stale result is never served.
	lc.persistAndComplete(store)
	return nil
}

// setupTasks runs each task's setup hook and returns only the tasks that
// initialized successfully. A task whose Setup errors is logged and dropped
// from the run so it never processes a volume or finalizes with partial state.
// Tasks with no setup hook pass through unchanged.
func setupTasks(jobs []Task, store storage.Store) []Task {
	out := make([]Task, 0, len(jobs))
	for _, job := range jobs {
		if s, ok := job.(TaskSetup); ok {
			if err := s.Setup(store); err != nil {
				log.WithError(err).Errorf("disabling %s job: setup failed", job.Name())
				continue
			}
		}
		out = append(out, job)
	}
	return out
}

// finalizeTask dispatches Finalize to whichever per-volume interface the
// Task satisfies. MountTask and MachoWalkTask both declare Finalize
// separately so we type-assert to the matching surface.
func finalizeTask(t Task) error {
	if mw, ok := t.(MachoWalkTask); ok {
		return mw.Finalize()
	}
	if mt, ok := t.(MountTask); ok {
		return mt.Finalize()
	}
	return nil
}

func filterJobsByVolume(jobs []Task, typ string) []Task {
	out := make([]Task, 0, len(jobs))
	for _, j := range jobs {
		if taskNeedsVolume(j, typ) {
			out = append(out, j)
		}
	}
	return out
}

// taskNeedsVolume reports whether a Task wants the given volume type by
// dispatching through the richer per-volume interfaces. A Task that
// implements neither MountTask nor MachoWalkTask never sees a volume.
func taskNeedsVolume(t Task, typ string) bool {
	if mw, ok := t.(MachoWalkTask); ok {
		return mw.Needs(typ)
	}
	if mt, ok := t.(MountTask); ok {
		return mt.Needs(typ)
	}
	return false
}

// processVolumePair mounts the requested volume on both sides, runs the
// MachoWalkTask fan-out plus any remaining MountTasks, then releases the
// pair. Release fires even when a task errors so we never leak a mount.
//
// The returned errored set names the tasks whose work for this volume failed
// (mount, scan, or per-volume hook). The orchestrator uses it to keep those
// tasks from being marked complete in the cache. A mount failure poisons
// every active task for the volume because none of them saw the data.
func processVolumePair(
	typ string,
	oldPresent, newPresent bool,
	oldSession, newSession ipswVolumeFileSession,
	jobs []Task,
) (error, map[string]bool) {
	utils.Indent(log.Info, 2)(fmt.Sprintf("Scanning %s volume", volumeLabel(typ)))

	errored := make(map[string]bool)

	var roots volumeRoots
	var mountErrs []error
	if oldPresent {
		if oldSession == nil {
			mountErrs = append(mountErrs, fmt.Errorf("Old IPSW mount session is not initialized"))
		} else if r, err := oldSession.Root(typ); err != nil {
			mountErrs = append(mountErrs, fmt.Errorf("failed to mount Old %s volume: %w", typ, err))
		} else {
			roots.old = r
		}
	}
	if newPresent {
		if newSession == nil {
			mountErrs = append(mountErrs, fmt.Errorf("New IPSW mount session is not initialized"))
		} else if r, err := newSession.Root(typ); err != nil {
			mountErrs = append(mountErrs, fmt.Errorf("failed to mount New %s volume: %w", typ, err))
		} else {
			roots.new = r
		}
	}

	// Session-fallback mounts for absent sides: when an active
	// SessionFallbackTask wants this volume, ask the session to resolve the
	// missing side itself (mount.Session.Root("sys") falls back to the
	// filesystem DMG on pre-cryptex IPSWs). The fallback root is handed only
	// to tasks that asked for it; a fallback mount failure is logged and the
	// requesting task errors downstream on the empty root. releaseVolumePair
	// releases typ on both sessions, covering these mounts too.
	if len(mountErrs) == 0 && sessionFallbackWanted(jobs, typ) {
		if !oldPresent && oldSession != nil {
			if r, err := oldSession.Root(typ); err != nil {
				log.WithError(err).Warnf("failed to mount Old %s fallback volume", typ)
			} else {
				roots.oldFallback = r
			}
		}
		if !newPresent && newSession != nil {
			if r, err := newSession.Root(typ); err != nil {
				log.WithError(err).Warnf("failed to mount New %s fallback volume", typ)
			} else {
				roots.newFallback = r
			}
		}
	}

	var scanErrs []error
	if len(mountErrs) == 0 {
		scanErrs = runVolumeTasks(typ, roots, jobs)
		for _, err := range scanErrs {
			if name, ok := taskNameFromError(err); ok {
				errored[name] = true
			}
		}
	} else {
		// No task processed the volume, so every active task is incomplete.
		for _, j := range jobs {
			errored[j.Name()] = true
		}
	}

	releaseErr := releaseVolumePair(typ, oldSession, newSession)

	return errors.Join(errors.Join(mountErrs...), errors.Join(scanErrs...), releaseErr), errored
}

// volumeRoots carries the mounted roots for one volume pass. old/new are the
// strict per-volume mounts (empty when the side lacks the volume);
// oldFallback/newFallback are session-resolved roots for absent sides,
// populated only when an active [SessionFallbackTask] asked for them and
// handed only to such tasks.
type volumeRoots struct {
	old, new                 string
	oldFallback, newFallback string
}

// sessionFallbackWanted reports whether any active job wants the session-
// resolved fallback root for absent sides of this volume.
func sessionFallbackWanted(jobs []Task, typ string) bool {
	for _, j := range jobs {
		if f, ok := j.(SessionFallbackTask); ok && f.WantsSessionFallback(typ) {
			return true
		}
	}
	return false
}

// runVolumeTasks dispatches MachoWalkTasks via a single shared Mach-O walk
// per side, then runs any remaining MountTasks sequentially. A task that
// implements both interfaces is treated as a MachoWalkTask. Tasks that
// fail during the shared walk are disabled across both sides of the
// current volume so the new-side closure never sees a half-populated
// old-side state.
func runVolumeTasks(typ string, roots volumeRoots, jobs []Task) []error {
	var errs []error

	// Resolve every mount root to its filesystem root (<mount>/root on
	// APFS-FUSE) once so the Mach-O walk and every MountTask key their entries
	// against the same path. Without it, MountTasks surface a spurious
	// top-level "root/" volume. MountedFilesystemRoot is a no-op for roots
	// already at the filesystem root.
	roots = normalizeVolumeRoots(roots)

	var machoTasks []MachoWalkTask
	var mountTasks []MountTask
	for _, j := range jobs {
		if mw, ok := j.(MachoWalkTask); ok {
			machoTasks = append(machoTasks, mw)
			continue
		}
		if mt, ok := j.(MountTask); ok {
			mountTasks = append(mountTasks, mt)
			continue
		}
		log.Warnf("%s job is neither MachoWalkTask nor MountTask; skipping", j.Name())
	}

	// Track which MachoWalkTasks are disabled for this volume. A disable
	// from the old-side walk also suppresses the new-side handler and the
	// EndVolume hook to avoid mixing partial state.
	disabled := make(map[string]bool, len(machoTasks))
	machoByName := make(map[string]MachoWalkTask, len(machoTasks))
	for _, mw := range machoTasks {
		machoByName[mw.Name()] = mw
	}

	for _, mw := range machoTasks {
		if err := mw.BeginVolume(typ); err != nil {
			disabled[mw.Name()] = true
			abortMachoVolume(mw, typ)
			errs = append(errs, fmt.Errorf("%s: BeginVolume(%s): %w", mw.Name(), typ, err))
		}
	}

	runSide := func(root string, side Side) {
		if root == "" {
			return
		}
		handlers := make([]search.NamedMachoScanHandler, 0, len(machoTasks))
		for _, mw := range machoTasks {
			if disabled[mw.Name()] {
				continue
			}
			h := mw.MachoHandler(typ, side)
			if h == nil {
				continue
			}
			handlers = append(handlers, search.NamedMachoScanHandler{
				Task:   mw.Name(),
				Handle: h,
			})
		}
		if len(handlers) == 0 {
			return
		}
		if err := search.ForEachMachoInMountMulti(root, handlers); err != nil {
			for _, perTask := range splitJoinedErrors(err) {
				if name, ok := taskNameFromError(perTask); ok {
					disabled[name] = true
					if mw, ok := machoByName[name]; ok {
						abortMachoVolume(mw, typ)
					}
					// Keep the task name as the leading prefix so the
					// orchestrator can attribute the failure back to the
					// task (and skip its cache completion sentinel).
					errs = append(errs, fmt.Errorf("%s: %s mount %s: %w", name, side, root, perTask))
					continue
				}
				errs = append(errs, fmt.Errorf("%s mount %s: %w", side, root, perTask))
			}
		}
	}
	runSide(roots.old, SideOld)
	runSide(roots.new, SideNew)

	for _, mw := range machoTasks {
		if disabled[mw.Name()] {
			continue
		}
		if err := mw.EndVolume(typ); err != nil {
			errs = append(errs, fmt.Errorf("%s: EndVolume(%s): %w", mw.Name(), typ, err))
		}
	}

	for _, mt := range mountTasks {
		oldRoot, newRoot := roots.old, roots.new
		if f, ok := mt.(SessionFallbackTask); ok && f.WantsSessionFallback(typ) {
			if oldRoot == "" {
				oldRoot = roots.oldFallback
			}
			if newRoot == "" {
				newRoot = roots.newFallback
			}
		}
		if err := mt.ProcessVolume(typ, oldRoot, newRoot); err != nil {
			errs = append(errs, fmt.Errorf("%s: %w", mt.Name(), err))
		}
	}
	return errs
}

// normalizeVolumeRoots resolves each non-empty mount root to its filesystem
// root via utils.MountedFilesystemRoot, so APFS-FUSE mounts descend into
// <mount>/root. It is a no-op for roots already at the filesystem root and for
// empty (absent-side) roots.
func normalizeVolumeRoots(r volumeRoots) volumeRoots {
	norm := func(p string) string {
		if p == "" {
			return ""
		}
		return utils.MountedFilesystemRoot(p)
	}
	r.old, r.new = norm(r.old), norm(r.new)
	r.oldFallback, r.newFallback = norm(r.oldFallback), norm(r.newFallback)
	return r
}

func abortMachoVolume(mw MachoWalkTask, typ string) {
	if aborter, ok := mw.(MachoVolumeAborter); ok {
		aborter.AbortVolume(typ)
	}
}

// splitJoinedErrors flattens an [errors.Join] result back into its
// constituent errors so per-task attribution survives a join.
func splitJoinedErrors(err error) []error {
	type joined interface{ Unwrap() []error }
	if j, ok := err.(joined); ok {
		out := j.Unwrap()
		if len(out) > 0 {
			return out
		}
	}
	return []error{err}
}

// taskNameFromError extracts the task-name prefix that
// [search.ForEachMachoInMountMulti] uses to attribute per-handler
// failures, so the orchestrator can mark the task disabled across the
// remainder of this volume.
func taskNameFromError(err error) (string, bool) {
	if err == nil {
		return "", false
	}
	msg := err.Error()
	if i := strings.Index(msg, ": "); i > 0 {
		name := msg[:i]
		if name == "" || name == "__walk__" {
			return "", false
		}
		return name, true
	}
	return "", false
}

func releaseVolumePair(typ string, oldSession, newSession ipswVolumeFileSession) error {
	var errs []error
	if oldSession != nil {
		if err := oldSession.Release(typ); err != nil {
			errs = append(errs, fmt.Errorf("failed to release Old %s volume: %w", typ, err))
		}
	}
	if newSession != nil {
		if err := newSession.Release(typ); err != nil {
			errs = append(errs, fmt.Errorf("failed to release New %s volume: %w", typ, err))
		}
	}
	return errors.Join(errs...)
}

// runTopLevelTasks invokes Parse on each top-level task that opts in via
// the registered filter. A MemoryStore is allocated for the duration of
// the call and closed afterward, providing the same [TaskSetup] surface
// mount-based jobs receive. Errors are logged per-task and the loop
// continues so a single failure does not poison sibling tasks.
//
// The cache lifecycle mirrors runVolumeJobsAcrossSessions exactly: after
// setup, resolve each CacheableTask's scope and hydrate the hits; a hydrated
// task has its result published by Hydrate, so its Parse is skipped. Fresh
// tasks Parse, then persistAndComplete writes their rows and stamps the
// completion sentinel. A fully-cached warm rerun therefore runs ZERO Parse
// work for every cacheable top-level task.
//
// Top-level tasks (kexts / kdks / firmwares / iboot / sandbox) run BEFORE
// the volume-major orchestrator today because the kernelcache parse
// produces state (Kernel.Path, sameKernel) that the sandbox task and the
// mount-based jobs may consult. The future shape (post-cache landing)
// moves them after MountTask Finalize per the migration plan.
func (d *Diff) runTopLevelTasks(ctx context.Context, tasks []TopLevelTask) error {
	if len(tasks) == 0 {
		return nil
	}
	store, cleanup := d.acquireStore()
	if cleanup != nil {
		defer cleanup()
	}

	for _, t := range tasks {
		if s, ok := t.(TaskSetup); ok {
			if err := s.Setup(store); err != nil {
				log.WithError(err).Errorf("failed to set up %s task", t.Name())
			}
		}
	}

	// Cache lifecycle: resolve each CacheableTask's scope, hydrate the ones
	// whose exact (task, version, options, inputs) tuple is already complete
	// in the store, and track per-task errors so a task that fails any step is
	// never marked complete. Hydrated tasks have their result published by
	// Hydrate, so their Parse is skipped entirely.
	lc := newCacheLifecycle(d.Old.Info, d.New.Info, topLevelTasksAsTaskSlice(tasks), store)

	for _, t := range tasks {
		if lc.isHydrated(t) {
			continue
		}
		if err := t.Parse(ctx, d); err != nil {
			lc.markErrored(map[string]bool{t.Name(): true})
			log.WithError(err).Errorf("failed to parse %s", t.Name())
		}
	}

	// Persist freshly-parsed cacheable tasks and stamp their completion
	// sentinel. Hydrated tasks already have their rows and sentinel; tasks with
	// any recorded error are skipped so a stale result is never served.
	lc.persistAndComplete(store)

	for _, t := range tasks {
		if c, ok := t.(TaskCleanup); ok {
			if err := c.Cleanup(); err != nil {
				log.WithError(err).Warnf("failed to clean up %s task", t.Name())
			}
		}
	}
	return nil
}

// topLevelTasksAsTaskSlice widens a []TopLevelTask to the []Task the
// volume-agnostic cache lifecycle consumes. Every TopLevelTask embeds Task,
// so the conversion is a straight copy.
func topLevelTasksAsTaskSlice(tasks []TopLevelTask) []Task {
	out := make([]Task, len(tasks))
	for i, t := range tasks {
		out[i] = t
	}
	return out
}
