// Package diff provides a way to diff two ipsws
package diff

import (
	"archive/zip"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"sync"
	"unicode"

	"github.com/apex/log"
	"github.com/blacktop/go-macho"
	dcmd "github.com/blacktop/ipsw/internal/commands/dsc"
	"github.com/blacktop/ipsw/internal/commands/dwarf"
	"github.com/blacktop/ipsw/internal/commands/ent"
	kcmd "github.com/blacktop/ipsw/internal/commands/kernel"
	mcmd "github.com/blacktop/ipsw/internal/commands/macho"
	mountcmd "github.com/blacktop/ipsw/internal/commands/mount"
	"github.com/blacktop/ipsw/internal/diff/storage"
	"github.com/blacktop/ipsw/internal/utils"
	"github.com/blacktop/ipsw/pkg/dyld"
	"github.com/blacktop/ipsw/pkg/iboot"
	"github.com/blacktop/ipsw/pkg/img4"
	"github.com/blacktop/ipsw/pkg/info"
	"github.com/blacktop/ipsw/pkg/kernelcache"
	otapkg "github.com/blacktop/ipsw/pkg/ota"
	"github.com/blacktop/ipsw/pkg/signature"
	"golang.org/x/exp/maps"
)

type kernel struct {
	Path    string
	Version *kernelcache.Version
	Kexts   []string
}

type mount struct {
	DmgPath      string
	MountPath    string
	IsMounted    bool
	CleanupPaths []string
}

type PlistDiff struct {
	New     map[string]string `json:"new,omitempty"`
	Removed []string          `json:"removed,omitempty"`
	Updated map[string]string `json:"changed,omitempty"`
}

type FileDiff struct {
	New     map[string][]string `json:"new,omitempty"`
	Removed map[string][]string `json:"removed,omitempty"`
	// Updated map[string]string `json:"changed,omitempty"`
}

type IBootDiff struct {
	Versions []string            `json:"versions,omitempty"`
	New      map[string][]string `json:"new,omitempty"`
	Removed  map[string][]string `json:"removed,omitempty"`
}

type Config struct {
	Title         string
	IpswOld       string
	IpswNew       string
	KDKs          []string
	LaunchD       bool
	Firmware      bool
	Features      bool
	Files         bool
	Localizations bool
	Sandbox       bool
	CStrings      bool
	FuncStarts    bool
	Entitlements  bool
	AllowList     []string
	BlockList     []string
	PemDB         string
	Signatures    string
	Output        string
	Verbose       bool
	AEAKeyDB      string
	AEAKeyVal     string
	AEAInsecure   bool
	// Cache holds the persistent-cache lifecycle options used in IPSW
	// mode. A zero value disables persistent caching and falls back to
	// the per-orchestrator MemoryStore that the directory/OTA paths use.
	Cache CacheConfig
}

// CacheConfig mirrors the CLI flags that govern the SQLite-backed diff
// cache. It is consumed by [Diff.Diff] after parsing IPSW Info structs;
// non-IPSW input modes ignore it.
type CacheConfig struct {
	// Dir overrides the default cache directory. Empty means use
	// os.UserCacheDir() + "/ipsw/diffs/".
	Dir string
	// NoCache disables persistent storage; a temp SQLite DB is used and
	// removed on exit.
	NoCache bool
	// Clean deletes any existing cache DB for this pair before opening.
	Clean bool
	// MaxBytes is the LRU eviction threshold applied to the cache
	// directory after the run finishes. Zero disables eviction.
	MaxBytes int64
}

// Context is the context for the diff
type Context struct {
	IPSWPath        string
	InputMode       inputMode
	Info            *info.Info
	Version         string
	Build           string
	Folder          string
	Mount           map[string]mount
	SystemOsDmgPath string
	MountPath       string
	IsMounted       bool
	IsMacOS         bool
	Kernel          kernel
	DSC             string
	Webkit          string
	KDK             string
	PemDB           string

	// otaFile is the parsed OTA archive. Nil unless InputMode == inputModeOTA.
	otaFile *otapkg.AA
	// payloadRoot is the extracted OTA payload filesystem root.
	payloadRoot  string
	payloadReady bool

	mu *sync.Mutex
}

// Diff is the diff
type Diff struct {
	Title string `json:"title,omitempty"`

	Old Context `json:"-"`
	New Context `json:"-"`

	Kexts         *mcmd.MachoDiff            `json:"kexts,omitempty"`
	KDKs          string                     `json:"kdks,omitempty"`
	Ents          map[string]string          `json:"ents,omitempty"`
	Dylibs        *mcmd.MachoDiff            `json:"dylibs,omitempty"`
	Machos        map[string]*mcmd.MachoDiff `json:"machos,omitempty"`
	Firmwares     *mcmd.MachoDiff            `json:"firmwares,omitempty"`
	IBoot         *IBootDiff                 `json:"iboot,omitempty"`
	Launchd       string                     `json:"launchd,omitempty"`
	Sandbox       string                     `json:"sandbox,omitempty"`
	Features      map[string]*PlistDiff      `json:"features,omitempty"`
	Files         *FileDiff                  `json:"files,omitempty"`
	Localizations map[string]*PlistDiff      `json:"localizations,omitempty"`
	tmpDir        string                     `json:"-"`
	conf          *Config
	oldSession    *mountcmd.Session // IPSW mode only: mounts each OS volume once
	newSession    *mountcmd.Session // IPSW mode only: mounts each OS volume once
	sameKernel    bool              // IPSW mode only: BuildManifest digests match
	sameVolumes   map[string]bool   // IPSW mode only: dmg type -> BuildManifest digests match
	// store is the cache backend used by orchestrators; nil means each
	// orchestrator allocates its own ephemeral MemoryStore. The CLI sets
	// this via SetStore so a single SQLiteStore spans every task in a run.
	store storage.Store
}

// SetStore installs a shared cache backend used by the volume-major and
// top-level orchestrators. Passing nil restores the default behavior of
// allocating a per-run MemoryStore for each orchestrator call.
func (d *Diff) SetStore(s storage.Store) { d.store = s }

// New news the diff
func New(conf *Config) *Diff {
	if len(conf.KDKs) == 0 {
		return &Diff{
			Title: conf.Title,
			Old: Context{
				IPSWPath: conf.IpswOld,
				Mount:    make(map[string]mount),
			},
			New: Context{
				IPSWPath: conf.IpswNew,
				Mount:    make(map[string]mount),
			},
			conf: conf,
		}
	}
	return &Diff{
		Title: conf.Title,
		Old: Context{
			IPSWPath: conf.IpswOld,
			Mount:    make(map[string]mount),
			KDK:      conf.KDKs[0],
		},
		New: Context{
			IPSWPath: conf.IpswNew,
			Mount:    make(map[string]mount),
			KDK:      conf.KDKs[1],
		},
		conf: conf,
	}
}

func (d *Diff) SetOutput(output string) {
	if d.conf == nil {
		d.conf = &Config{Output: output}
	} else {
		d.conf.Output = output
	}
}

func (d *Diff) TitleToFilename() string {
	var out strings.Builder
	lastUnderscore := false
	writeUnderscore := func() {
		if lastUnderscore {
			return
		}
		out.WriteByte('_')
		lastUnderscore = true
	}

	for _, r := range d.Title {
		switch {
		case unicode.IsLetter(r), unicode.IsDigit(r), r == '_', r == '-', r == ',':
			out.WriteRune(r)
			lastUnderscore = false
		case unicode.IsSpace(r), r == '.', r == '(', r == ')', r == '/', r == '\\', r == ':':
			writeUnderscore()
		default:
			writeUnderscore()
		}
	}

	filename := strings.Trim(out.String(), "_")
	if filename == "" {
		return "diff"
	}

	return filename
}

func ipswSessionExtractDir(tmpDir, side string) string {
	return filepath.Join(tmpDir, "ipsw-session", side)
}

func (d *Diff) getInfo() (err error) {
	mode, err := detectInputMode(d.Old.IPSWPath, d.New.IPSWPath)
	if err != nil {
		return err
	}
	d.Old.PemDB = d.conf.PemDB
	d.New.PemDB = d.conf.PemDB

	if mode == inputModeDirectory {
		d.Old.InputMode = inputModeDirectory
		d.New.InputMode = inputModeDirectory
		configureDirectoryContext(&d.Old)
		configureDirectoryContext(&d.New)

		if d.Title == "" {
			d.Title = fmt.Sprintf("%s .vs %s", d.Old.Build, d.New.Build)
		}

		return nil
	}

	// Probe both sides for OTA before committing to IPSW mode.
	oldOTA, oldInfo, oldErr := tryOpenOTA(d.Old.IPSWPath, d.conf)
	newOTA, newInfo, newErr := tryOpenOTA(d.New.IPSWPath, d.conf)

	// Handle fatal errors — clean up any opened handle.
	if oldErr != nil {
		if newOTA != nil {
			newOTA.Close()
		}
		return fmt.Errorf("failed to probe 'Old' input: %w", oldErr)
	}
	if newErr != nil {
		if oldOTA != nil {
			oldOTA.Close()
		}
		return fmt.Errorf("failed to probe 'New' input: %w", newErr)
	}

	// Classify symmetrically.
	switch {
	case oldOTA != nil && newOTA != nil:
		// Both are OTAs — validate scope (Phase 1: full OTAs only).
		if err := validateOTAScope(oldInfo); err != nil {
			oldOTA.Close()
			newOTA.Close()
			return fmt.Errorf("'Old' OTA: %w", err)
		}
		if err := validateOTAScope(newInfo); err != nil {
			oldOTA.Close()
			newOTA.Close()
			return fmt.Errorf("'New' OTA: %w", err)
		}

		d.Old.otaFile = oldOTA
		d.New.otaFile = newOTA
		configureOTAContext(&d.Old, oldInfo, d.tmpDir)
		configureOTAContext(&d.New, newInfo, d.tmpDir)

		if isMacOSOTA(oldInfo) || isMacOSOTA(newInfo) {
			d.Old.IsMacOS = true
			d.New.IsMacOS = true
		}

		if d.Title == "" {
			d.Title = fmt.Sprintf(
				"%s (%s) .vs %s (%s)",
				d.Old.Version, d.Old.Build,
				d.New.Version, d.New.Build,
			)
		}
		return nil

	case oldOTA == nil && newOTA == nil:
		// Neither is OTA — fall through to IPSW mode.

	default:
		// Mixed: one OTA, one not.
		if oldOTA != nil {
			oldOTA.Close()
		}
		if newOTA != nil {
			newOTA.Close()
		}
		return fmt.Errorf(
			"inputs must both be IPSW files, OTA files, " +
				"or directories of patched OTA DMGs",
		)
	}

	// IPSW mode.
	d.Old.InputMode = inputModeIPSW
	d.New.InputMode = inputModeIPSW

	d.Old.Info, err = info.Parse(d.Old.IPSWPath)
	if err != nil {
		return fmt.Errorf("failed to parse 'Old' IPSW: %v", err)
	}
	d.New.Info, err = info.Parse(d.New.IPSWPath)
	if err != nil {
		return fmt.Errorf("failed to parse 'New' IPSW: %v", err)
	}

	d.Old.Version = d.Old.Info.Plists.BuildManifest.ProductVersion
	d.Old.Build = d.Old.Info.Plists.BuildManifest.ProductBuildVersion
	d.Old.Folder, err = d.Old.Info.GetFolder()
	if err != nil {
		log.Errorf("failed to get folder from 'Old' IPSW metadata: %v", err)
	}
	d.Old.Folder = filepath.Join(d.tmpDir, d.Old.Folder)

	d.New.Version = d.New.Info.Plists.BuildManifest.ProductVersion
	d.New.Build = d.New.Info.Plists.BuildManifest.ProductBuildVersion
	d.New.Folder, err = d.New.Info.GetFolder()
	if err != nil {
		log.Errorf("failed to get folder from 'New' IPSW metadata: %v", err)
	}
	d.New.Folder = filepath.Join(d.tmpDir, d.New.Folder)

	if d.Title == "" {
		d.Title = fmt.Sprintf("%s (%s) .vs %s (%s)", d.Old.Version, d.Old.Build, d.New.Version, d.New.Build)
	}

	if d.Old.Info.IsMacOS() || d.New.Info.IsMacOS() {
		d.Old.IsMacOS = true
		d.New.IsMacOS = true
	}

	return nil
}

// Diff diffs the diff
func (d *Diff) Diff() (err error) {

	d.tmpDir, err = os.MkdirTemp(os.TempDir(), "ipsw-diff")
	if err != nil {
		return fmt.Errorf("failed to create temp dir: %v", err)
	}
	defer os.RemoveAll(d.tmpDir)

	if err := d.getInfo(); err != nil {
		return err
	}
	d.indexIdenticalIPSWArtifacts()

	// Install the persistent diff cache for IPSW mode. Directory and
	// OTA inputs lack BuildManifest digests so they fall back to the
	// per-orchestrator MemoryStore that acquireStore allocates.
	if cleanup := d.openCacheStore(); cleanup != nil {
		defer cleanup()
	}

	// Close OTA handles when done (after all extractions).
	if d.Old.otaFile != nil {
		defer d.Old.otaFile.Close()
	}
	if d.New.otaFile != nil {
		defer d.New.otaFile.Close()
	}

	directoryMode := d.Old.InputMode == inputModeDirectory
	otaMode := d.Old.InputMode == inputModeOTA

	if directoryMode {
		if unsupported := unsupportedFlagsForDirectoryMode(d.conf); len(unsupported) > 0 {
			log.Warnf("Directory inputs do not support %s; skipping those sections", strings.Join(unsupported, ", "))
		}
		log.Info("Mounting patched OTA DMGs")
		if err := d.mountSystemOsDMGs(); err != nil {
			return fmt.Errorf("failed to mount DMGs: %v", err)
		}
		defer d.unmountSystemOsDMGs()
	}

	if otaMode {
		if unsupported := unsupportedFlagsForOTAMode(d.conf); len(unsupported) > 0 {
			log.Warnf("OTA mode does not support %s; skipping those sections", strings.Join(unsupported, ", "))
		}
	}

	// Top-level kernel-derived tasks run in source order before mount
	// sessions exist: parseKernelcache populates d.Kexts and Kernel.Path
	// (which the sandbox task consumes); parseKDKs runs against the
	// per-side KDK paths; parseSandboxProfiles is gated on sameKernel.
	{
		var pre []TopLevelTask
		if directoryMode {
			log.Debug("Skipping KERNELCACHES for directory inputs")
		} else if d.sameKernel {
			log.Info("Kernelcache unchanged (BuildManifest digest); parsing version only")
			pre = append(pre, newKextsTask(d))
		} else {
			log.Info("Diffing KERNELCACHES")
			pre = append(pre, newKextsTask(d))
		}
		if d.Old.KDK != "" && d.New.KDK != "" {
			log.Info("Diffing KDKS")
			pre = append(pre, newKDKsTask(d))
		}
		if err := d.runTopLevelTasks(context.Background(), pre); err != nil {
			log.WithError(err).Error("failed to run pre-mount top-level tasks")
		}
	}

	if d.conf.Sandbox && !directoryMode {
		if d.sameKernel {
			utils.Indent(log.Warn, 2)("Skipping Sandbox Profiles (kernelcache unchanged)")
		} else {
			log.Info("Diffing Sandbox Profiles")
			if err := d.runTopLevelTasks(context.Background(), []TopLevelTask{newSandboxTask(d)}); err != nil {
				log.WithError(err).Error("failed to diff sandbox profiles")
			}
		}
	}

	if !directoryMode && !otaMode {
		// IPSW mode: mount each OS volume once per side via a session, shared by
		// the DSC parse and every later feature. defer Close() here so it
		// dominates every session.Root() call across the feature passes.
		// Keep old/new extracted DMGs isolated; IPSWs often reuse the same DMG
		// filenames across builds, and DmgInIPSW treats an existing extraction as
		// a cache hit.
		d.oldSession = mountcmd.NewSession(d.Old.IPSWPath, &mountcmd.Config{
			PemDB:      d.conf.PemDB,
			ExtractDir: ipswSessionExtractDir(d.tmpDir, "old"),
		})
		d.newSession = mountcmd.NewSession(d.New.IPSWPath, &mountcmd.Config{
			PemDB:      d.conf.PemDB,
			ExtractDir: ipswSessionExtractDir(d.tmpDir, "new"),
		})
		defer d.oldSession.Close()
		defer d.newSession.Close()
		// IPSW mode no longer pre-mounts "sys" here. The volume-major
		// orchestrator (runIPSWVolumeJobsForMode) lazily mounts each volume
		// when its phase runs and releases it before moving on.
	} else if otaMode {
		if err := d.mountSystemOsDMGs(); err != nil {
			return fmt.Errorf("failed to mount DMGs: %v", err)
		}
		defer d.unmountSystemOsDMGs()
	}

	if directoryMode || otaMode {
		// Legacy feature-major paths for non-IPSW input modes.
		if otaMode || !d.dscVolumeUnchanged() {
			log.Info("Diffing DYLD_SHARED_CACHES")
			if err := d.parseDSC(); err != nil {
				log.WithError(err).Error("failed to parse DSCs")
			}
		}
		log.Info("Diffing MachOs")
		if err := d.parseMachos(); err != nil {
			log.WithError(err).Error("failed to parse MachOs")
		}
		if d.conf.LaunchD && otaMode {
			log.Info("Diffing launchd PLIST")
			if err := d.parseLaunchdPlists(); err != nil {
				log.WithError(err).Error("failed to parse launchd plists")
			}
		}
	}
	// IPSW mode: DSC, MachOs, launchd, features, localizations, entitlements,
	// and files are all dispatched by the volume-major orchestrator below.

	if d.conf.Firmware {
		var fw []TopLevelTask
		log.Info("Diffing Firmware")
		fw = append(fw, newFirmwaresTask(d))
		if !directoryMode {
			log.Info("Diffing iBoot")
			fw = append(fw, newIBootTask(d))
		}
		if err := d.runTopLevelTasks(context.Background(), fw); err != nil {
			log.WithError(err).Error("failed to run firmware top-level tasks")
		}
	}

	// IPSW-mode Feature Flags is handled by featuresJob via the volume-major
	// orchestrator (see runIPSWVolumeJobsForMode). OTA and Directory modes
	// still run the legacy parseFeatureFlags here.
	if d.conf.Features && (directoryMode || otaMode) {
		log.Info("Diffing Feature Flags")
		if err := d.parseFeatureFlags(); err != nil {
			log.WithError(err).Error("failed to parse feature flags")
		}
	}

	// IPSW-mode Localizations handled by locsJob via the orchestrator.
	if d.conf.Localizations && (directoryMode || otaMode) {
		log.Info("Diffing Localizations")
		if err := d.parseLocalizations(); err != nil {
			log.WithError(err).Error("failed to parse localizations")
		}
	}

	// IPSW-mode Entitlements handled by entsJob via the orchestrator.
	if d.conf.Entitlements && (directoryMode || otaMode) {
		log.Info("Diffing ENTITLEMENTS")
		d.Ents, err = d.parseEntitlements()
		if err != nil {
			log.WithError(err).Error("failed to parse entitlements")
		}
	}

	if err := d.runIPSWVolumeJobsForMode(directoryMode, otaMode); err != nil {
		log.WithError(err).Error("failed to run volume-major jobs")
	}

	return nil
}

// runIPSWVolumeJobsForMode dispatches the per-flag jobs that have been
// migrated to the volume-major orchestrator. OTA and Directory modes fall
// back to the legacy feature-major parser for Files; the other parsers
// (DSC, launchd) are handled outside this function for those modes.
//
// IPSW mode builds a job list from d.conf.* flags and runs the orchestrator.
// As more parsers are ported, additional entries get appended here.
func (d *Diff) runIPSWVolumeJobsForMode(directoryMode, otaMode bool) error {
	if directoryMode || otaMode {
		if d.conf.Files {
			log.Info("Diffing Files")
			if err := d.parseFiles(); err != nil {
				log.WithError(err).Error("failed to parse files")
			}
		}
		return nil
	}

	var jobs []Task

	// DSC always runs in IPSW mode (the existing behavior); the per-volume
	// skip in the orchestrator handles the "sys unchanged" case.
	if !d.dscVolumeUnchanged() {
		log.Info("Diffing DYLD_SHARED_CACHES")
		jobs = append(jobs, newDSCJob(d))
	} else {
		log.Info("Skipping DYLD_SHARED_CACHES (SystemOS DMG unchanged)")
	}

	if d.conf.LaunchD {
		if d.ipswVolumeUnchanged("fs") {
			log.Info("Skipping launchd PLIST (FileSystem DMG unchanged)")
		} else {
			log.Info("Diffing launchd PLIST")
			jobs = append(jobs, newLaunchdJob(d))
		}
	}

	if d.conf.Features {
		if d.allIPSWOSVolumesUnchanged() {
			log.Info("Skipping Feature Flags (OS DMGs unchanged)")
		} else {
			log.Info("Diffing Feature Flags")
			jobs = append(jobs, newFeaturesJob(d))
		}
	}

	if d.conf.Localizations {
		if d.allIPSWOSVolumesUnchanged() {
			log.Info("Skipping Localizations (OS DMGs unchanged)")
		} else {
			log.Info("Diffing Localizations")
			jobs = append(jobs, newLocalizationsJob(d))
		}
	}

	if d.conf.Entitlements {
		if d.allIPSWOSVolumesUnchanged() {
			log.Info("Skipping ENTITLEMENTS (OS DMGs unchanged)")
		} else {
			log.Info("Diffing ENTITLEMENTS")
			jobs = append(jobs, newEntitlementsJob(d))
		}
	}

	// MachOs always runs in IPSW mode (the existing behavior); the per-volume
	// skip in the orchestrator handles the "all OS DMGs unchanged" case.
	if d.allIPSWOSVolumesUnchanged() {
		log.Info("Skipping MachOs (OS DMGs unchanged)")
	} else {
		log.Info("Diffing MachOs")
		jobs = append(jobs, newMachosJob(d))
	}

	if d.conf.Files {
		log.Info("Diffing Files")
		jobs = append(jobs, newFilesJob(d))
	}

	if len(jobs) == 0 {
		return nil
	}
	return d.runIPSWVolumePhases(jobs)
}

// mountSystemOsDMGs mounts the OS volumes for OTA and directory inputs. IPSW
// mode instead mounts each volume once via a per-side mount.Session in Diff().
func (d *Diff) mountSystemOsDMGs() (err error) {
	switch d.Old.InputMode {
	case inputModeDirectory:
		log.Info("Mounting 'Old' patched OTA DMGs")
		if err := mountDirectoryDMGs(&d.Old); err != nil {
			return err
		}
		log.Info("Mounting 'New' patched OTA DMGs")
		if err := mountDirectoryDMGs(&d.New); err != nil {
			return err
		}
		return nil
	case inputModeOTA:
		log.Info("Extracting 'Old' OTA system cryptex")
		if err := mountOTACryptexes(&d.Old); err != nil {
			return err
		}
		log.Info("Extracting 'New' OTA system cryptex")
		if err := mountOTACryptexes(&d.New); err != nil {
			unmountOTACryptexes("Old", &d.Old)
			return err
		}
		return nil
	default:
		return fmt.Errorf("mountSystemOsDMGs: IPSW mode mounts via mount.Session, not this path")
	}
}

func (d *Diff) unmountSystemOsDMGs() error {
	if d.Old.InputMode == inputModeDirectory {
		releaseDirectoryMounts("Old", d.Old.Mount)
		releaseDirectoryMounts("New", d.New.Mount)
		return nil
	}
	if d.Old.InputMode == inputModeOTA {
		unmountOTACryptexes("Old", &d.Old)
		unmountOTACryptexes("New", &d.New)
		return nil
	}
	// IPSW mode unmounts via mount.Session.Close.
	return nil
}

func (d *Diff) extractKernelcaches() error {
	if d.Old.InputMode == inputModeOTA {
		oldMember, newMember, err := selectOTAKernelcachePair(&d.Old, &d.New)
		if err != nil {
			return fmt.Errorf("failed to select OTA kernelcache pair: %w", err)
		}
		if err := extractOTAKernelcache(&d.Old, oldMember, d.Old.Folder); err != nil {
			return fmt.Errorf("failed to extract 'Old' OTA kernelcache: %w", err)
		}
		if err := extractOTAKernelcache(&d.New, newMember, d.New.Folder); err != nil {
			return fmt.Errorf("failed to extract 'New' OTA kernelcache: %w", err)
		}
		return nil
	}

	// IPSW mode.
	if d.Old.IsMacOS || d.New.IsMacOS {
		if out, err := kernelcache.Extract(d.Old.IPSWPath, d.Old.Folder, macOSKernelcacheDevice); err != nil {
			return fmt.Errorf("failed to extract kernelcaches from 'Old' IPSW: %v", err)
		} else {
			d.Old.Kernel.Path = maps.Keys(out)[0]
		}
		if out, err := kernelcache.Extract(d.New.IPSWPath, d.New.Folder, macOSKernelcacheDevice); err != nil {
			return fmt.Errorf("failed to extract kernelcaches from 'New' IPSW: %v", err)
		} else {
			d.New.Kernel.Path = maps.Keys(out)[0]
		}
	} else {
		if _, err := kernelcache.Extract(d.Old.IPSWPath, d.Old.Folder, ""); err != nil {
			return fmt.Errorf("failed to extract kernelcaches from 'Old' IPSW: %v", err)
		}
		if _, err := kernelcache.Extract(d.New.IPSWPath, d.New.Folder, ""); err != nil {
			return fmt.Errorf("failed to extract kernelcaches from 'New' IPSW: %v", err)
		}
		for kmodel := range d.Old.Info.Plists.GetKernelCaches() {
			if _, ok := d.Old.Info.Plists.GetKernelCaches()[kmodel]; !ok {
				return fmt.Errorf("failed to find kernelcache for %s in 'Old' IPSW: `ipsw diff` expects you to compare 2 versions of the same IPSW device type", kmodel)
			} else if len(d.Old.Info.Plists.GetKernelCaches()[kmodel]) == 0 {
				return fmt.Errorf("failed to find kernelcache for %s in 'Old' IPSW", kmodel)
			}
			if _, ok := d.New.Info.Plists.GetKernelCaches()[kmodel]; !ok {
				return fmt.Errorf("failed to find kernelcache for %s in 'New' IPSW: `ipsw diff` expects you to compare 2 versions of the same IPSW device type", kmodel)
			} else if len(d.New.Info.Plists.GetKernelCaches()[kmodel]) == 0 {
				return fmt.Errorf("failed to find kernelcache for %s in 'New' IPSW", kmodel)
			}
			kcache1 := d.Old.Info.Plists.GetKernelCaches()[kmodel][0]
			kcache2 := d.New.Info.Plists.GetKernelCaches()[kmodel][0]
			d.Old.Kernel.Path = filepath.Join(d.Old.Folder, d.Old.Info.GetKernelCacheFileName(kcache1))
			d.New.Kernel.Path = filepath.Join(d.New.Folder, d.New.Info.GetKernelCacheFileName(kcache2))
			break // just use first kernelcache for now
		}
	}
	return nil
}

// ensureKernelcachePaths populates d.Old.Kernel.Path / d.New.Kernel.Path when
// they are empty. extractKernelcaches sets them as a side effect of the kexts
// task's Parse, but a warm cache hit on kexts SKIPS that Parse, so a sibling
// task that runs fresh (e.g. a partial-hit sandbox task) would otherwise find an
// empty path. The path check is the only guard: kernelcache.Extract re-does the
// full unzip+decompress every call (it does NOT skip an already-extracted file),
// so callers that just need the paths must go through here rather than calling
// extractKernelcaches directly.
func (d *Diff) ensureKernelcachePaths() error {
	if d.Old.Kernel.Path != "" && d.New.Kernel.Path != "" {
		return nil
	}
	return d.extractKernelcaches()
}

// parseKernelVersion parses only the kernelcache version (no KEXT diff) so the
// report's Kernel section still renders when the kernelcache is unchanged
// between builds and the full diff is skipped. Both builds share an identical
// kernelcache in that case, so one open supplies the version for both sides.
func (d *Diff) parseKernelVersion() error {
	path, err := d.extractOldKernelcache()
	if err != nil {
		return err
	}
	m, err := macho.Open(path)
	if err != nil {
		return fmt.Errorf("failed to open kernelcache: %v", err)
	}
	defer m.Close()
	version, err := kernelcache.GetVersion(m)
	if err != nil {
		return fmt.Errorf("failed to get kernelcache version: %v", err)
	}
	d.Old.Kernel.Version = version
	d.New.Kernel.Version = version
	return nil
}

// extractOldKernelcache resolves a single Old-side kernelcache on disk and
// returns its path, doing the least work that can produce one:
//
//   - a file extracted earlier in the current run is reused (kernelcache.Extract
//     has no such guard);
//   - only the Old IPSW is touched, and only one kernelcache member within it.
//
// Callers must therefore only use it when the New side is known to carry the
// same kernel (the sameKernel short-circuit); [Diff.extractKernelcaches] remains
// the path that populates both sides for the real KEXT diff.
func (d *Diff) extractOldKernelcache() (string, error) {
	if d.Old.Kernel.Path != "" {
		return d.Old.Kernel.Path, nil
	}
	member, ok := d.oldKernelcacheMember()
	if !ok {
		// No resolvable member: fall back to the both-sides extraction so the
		// version is still reported rather than silently dropped.
		if err := d.extractKernelcaches(); err != nil {
			return "", err
		}
		return d.Old.Kernel.Path, nil
	}
	fname := filepath.Join(d.Old.Folder, d.Old.Info.GetKernelCacheFileName(member))
	if _, err := os.Stat(fname); err == nil {
		d.Old.Kernel.Path = fname
		return fname, nil
	}
	out, err := kernelcache.ExtractWithInfo(d.Old.Info, d.Old.IPSWPath, d.Old.Folder, member)
	if err != nil {
		return "", fmt.Errorf("failed to extract 'Old' kernelcache %s: %w", member, err)
	}
	if len(out) == 0 {
		return "", fmt.Errorf("no kernelcache extracted for %s", member)
	}
	paths := maps.Keys(out)
	slices.Sort(paths)
	d.Old.Kernel.Path = paths[0]
	return d.Old.Kernel.Path, nil
}

// oldKernelcacheMember returns an Old-side BuildManifest path whose digest was
// compared successfully with the corresponding New-side path by the
// sameKernel fast-path predicate.
func (d *Diff) oldKernelcacheMember() (string, bool) {
	return matchingIPSWKernelcacheManifestMember(d.Old.Info, d.New.Info)
}

func (d *Diff) parseKernelcache() error {
	if err := d.extractKernelcaches(); err != nil {
		return err
	}

	m1, err := macho.Open(d.Old.Kernel.Path)
	if err != nil {
		return fmt.Errorf("failed to open kernelcache: %v", err)
	}
	d.Old.Kernel.Version, err = kernelcache.GetVersion(m1)
	if err != nil {
		return fmt.Errorf("failed to get kernelcache version: %v", err)
	}
	defer m1.Close()

	m2, err := macho.Open(d.New.Kernel.Path)
	if err != nil {
		return fmt.Errorf("failed to open kernelcache: %v", err)
	}
	d.New.Kernel.Version, err = kernelcache.GetVersion(m2)
	if err != nil {
		return fmt.Errorf("failed to get kernelcache version: %v", err)
	}
	defer m2.Close()

	sameKernel, err := filesSHA256Equal(d.Old.Kernel.Path, d.New.Kernel.Path)
	if err != nil {
		return fmt.Errorf("failed to compare extracted kernelcaches: %w", err)
	}
	if sameKernel {
		d.sameKernel = true
		utils.Indent(log.Warn, 2)("Skipping kernelcache symbolication and KEXT diff (extracted kernelcache unchanged)")
		return nil
	}

	// Wrapper bytes (UUID, build-root strings, embedded plist digests) often
	// differ across rebuilds of an otherwise-identical kernel. If every
	// functional segment (code, constants, mutable data, symbols) matches,
	// the kernel is functionally unchanged regardless of those differences.
	if kernelKeySegmentsEqual(m1, m2) {
		d.sameKernel = true
		utils.Indent(log.Warn, 2)("Skipping kernelcache symbolication and KEXT diff (functional segments unchanged; only UUID/build metadata differs)")
		return nil
	}

	var smap map[string]signature.SymbolMap
	if d.conf.Signatures != "" {
		smap = make(map[string]signature.SymbolMap)
		log.Info("Parsing Kernel Signatures")
		sigs, err := signature.Parse(d.conf.Signatures)
		if err != nil {
			return fmt.Errorf("failed to parse signatures: %v", err)
		}

		smap[m1.UUID().String()] = signature.NewSymbolMap()
		log.WithField("kernelcache", d.Old.Kernel.Path).Info("Symbolicating...")
		if err := smap[m1.UUID().String()].Symbolicate(d.Old.Kernel.Path, sigs, true); err != nil {
			log.Errorf("failed to symbolicate kernelcache: %v", err)
		}

		smap[m2.UUID().String()] = signature.NewSymbolMap()
		log.WithField("kernelcache", d.New.Kernel.Path).Info("Symbolicating...")
		if err := smap[m2.UUID().String()].Symbolicate(d.New.Kernel.Path, sigs, true); err != nil {
			log.Errorf("failed to symbolicate kernelcache: %v", err)
		}
	}

	d.Kexts, err = kcmd.Diff(m1, m2, &mcmd.DiffConfig{
		Markdown:   true,
		Color:      false,
		DiffTool:   "git",
		AllowList:  d.conf.AllowList,
		BlockList:  d.conf.BlockList,
		CStrings:   d.conf.CStrings,
		FuncStarts: d.conf.FuncStarts,
		SymMap:     smap,
		Verbose:    d.conf.Verbose,
	})
	if err != nil {
		return err
	}

	// // diff kexts
	// d.Old.Kernel.Kexts, err = kernelcache.KextList(d.Old.Kernel.Path, true)
	// if err != nil {
	// 	return err
	// }
	// d.New.Kernel.Kexts, err = kernelcache.KextList(d.New.Kernel.Path, true)
	// if err != nil {
	// 	return err
	// }
	// out, err := utils.GitDiff(
	// 	strings.Join(d.Old.Kernel.Kexts, "\n")+"\n",
	// 	strings.Join(d.New.Kernel.Kexts, "\n")+"\n",
	// 	&utils.GitDiffConfig{Color: false, Tool: "git"})
	// if err != nil {
	// 	return err
	// }
	// if len(out) == 0 {
	// 	d.Kexts = "- No differences found"
	// } else {
	// 	d.Kexts = "```diff\n" + out + "\n```"
	// }

	return nil
}

func (d *Diff) parseKDKs() (err error) {
	d.Old.KDK = kdkDwarfPath(d.Old.KDK)
	d.New.KDK = kdkDwarfPath(d.New.KDK)
	d.KDKs, err = dwarf.DiffStructures(d.Old.KDK, d.New.KDK, &dwarf.Config{
		Markdown: true,
		Color:    false,
		DiffTool: "git",
	})
	d.Old.KDK = kdkDisplayPath(d.Old.KDK)
	d.New.KDK = kdkDisplayPath(d.New.KDK)
	return
}

func (d *Diff) parseDSC() error {
	return d.diffDSCBetweenRoots(d.Old.MountPath, d.New.MountPath)
}

// diffDSCBetweenRoots is the shared DSC diff implementation used by both
// the legacy parseDSC (OTA / Directory mode, which sets Context.MountPath
// via their own mount setup) and the volume-major dscJob (IPSW mode, which
// receives roots from the orchestrator's "sys" phase).
func (d *Diff) diffDSCBetweenRoots(oldRoot, newRoot string) error {
	dscOLD, err := openDSCFromMount(oldRoot, d.Old.IsMacOS, d.Old.InputMode, "Old")
	if err != nil {
		return err
	}
	defer dscOLD.Close()

	dscNEW, err := openDSCFromMount(newRoot, d.New.IsMacOS, d.New.InputMode, "New")
	if err != nil {
		return err
	}
	defer dscNEW.Close()

	/* DIFF WEBKIT */

	d.Old.Webkit, err = dcmd.GetWebkitVersion(dscOLD)
	if err != nil {
		log.WithError(err).Error("failed to get WebKit version from 'old' DSC")
	}

	d.New.Webkit, err = dcmd.GetWebkitVersion(dscNEW)
	if err != nil {
		log.WithError(err).Error("failed to get WebKit version from 'new' DSC")
	}

	d.Dylibs, err = dcmd.Diff(dscOLD, dscNEW, d.dscDiffConfig())
	if err != nil {
		return err
	}

	return nil
}

// openDSCFromMount finds the dyld_shared_cache under mountRoot (filtered to
// arm64e for macOS IPSWs when applicable) and opens the first match.
func openDSCFromMount(mountRoot string, isMacOS bool, mode inputMode, side string) (*dyld.File, error) {
	dscs, err := dyld.GetDscPathsInMount(mountRoot, false, false)
	if err != nil {
		return nil, fmt.Errorf("failed to get DSC paths in %s: %v", mountRoot, err)
	}
	if len(dscs) == 0 {
		return nil, fmt.Errorf("no DSCs found in '%s' IPSW mount %s", side, mountRoot)
	}
	if isMacOS {
		var filtered []string
		r := regexp.MustCompile(fmt.Sprintf("%s(%s)%s", dyld.CacheRegex, "arm64e", dyld.CacheRegexEnding))
		for _, match := range dscs {
			if r.MatchString(match) {
				filtered = append(filtered, match)
			}
		}
		if len(filtered) == 0 && mode != inputModeOTA {
			return nil, fmt.Errorf("no dyld_shared_cache files found matching the specified archs 'arm64e'")
		}
		if len(filtered) > 0 {
			dscs = filtered
		}
	}
	dsc, err := dyld.Open(dscs[0])
	if err != nil {
		return nil, fmt.Errorf("failed to open DSC: %v", err)
	}
	return dsc, nil
}

func (d *Diff) ensureOTAPayloadFilesystems() error {
	if err := ensureOTAPayloadFilesystem(&d.Old); err != nil {
		return fmt.Errorf("failed to extract old OTA payload filesystem: %w", err)
	}
	if err := ensureOTAPayloadFilesystem(&d.New); err != nil {
		return fmt.Errorf("failed to extract new OTA payload filesystem: %w", err)
	}
	return nil
}

// parseEntitlements handles OTA and Directory input modes. IPSW mode is
// handled by entsJob via the volume-major orchestrator.
func (d *Diff) parseEntitlements() (map[string]string, error) {
	var oldMounts, newMounts map[string]mount
	switch d.Old.InputMode {
	case inputModeOTA:
		if err := d.ensureOTAPayloadFilesystems(); err != nil {
			return nil, err
		}
		oldMounts = otaDiffMounts(&d.Old)
		newMounts = otaDiffMounts(&d.New)
	case inputModeDirectory:
		oldMounts = d.Old.Mount
		newMounts = d.New.Mount
	default:
		return nil, fmt.Errorf("diff: parseEntitlements: IPSW mode uses entsJob via the volume-major orchestrator")
	}

	names := unionMountNames(oldMounts, newMounts)
	out := make(map[string]string)
	for _, name := range names {
		oldDB := make(map[string]string)
		if mnt, ok := oldMounts[name]; ok {
			ents, err := ent.GetDatabase(&ent.Config{
				Folder:            mnt.MountPath,
				LaunchConstraints: true,
			})
			if err != nil {
				return nil, fmt.Errorf("failed to scan entitlements in old %s: %w", name, err)
			}
			maps.Copy(oldDB, ents)
		}
		newDB := make(map[string]string)
		if mnt, ok := newMounts[name]; ok {
			ents, err := ent.GetDatabase(&ent.Config{
				Folder:            mnt.MountPath,
				LaunchConstraints: true,
			})
			if err != nil {
				return nil, fmt.Errorf("failed to scan entitlements in new %s: %w", name, err)
			}
			maps.Copy(newDB, ents)
		}
		rendered, err := renderEntitlementsDiff(oldDB, newDB)
		if err != nil {
			return nil, err
		}
		if entitlementsDiffHasContent(rendered) {
			out[name] = rendered
		}
	}
	return out, nil
}

// entitlementsDiffHasContent reports whether the rendered diff string
// contains actual diff data (as opposed to the "no differences" marker).
func entitlementsDiffHasContent(s string) bool {
	return s != "" && s != "- No differences found\n"
}

// renderEntitlementsDiff renders the markdown diff between two
// path-keyed entitlement databases. Shared by parseEntitlements
// (OTA/Directory) and entsJob (IPSW).
func renderEntitlementsDiff(oldDB, newDB map[string]string) (string, error) {
	return ent.DiffDatabases(oldDB, newDB, &ent.Config{
		Markdown: true,
		Color:    false,
		DiffTool: "git",
	})
}

// parseMachos handles OTA and Directory input modes. IPSW mode is handled
// by machosJob via the volume-major orchestrator.
func (d *Diff) parseMachos() (err error) {
	conf := d.machoDiffConfig()
	if d.Old.InputMode == inputModeOTA {
		if err := d.ensureOTAPayloadFilesystems(); err != nil {
			return err
		}
		d.Machos, err = diffMachosInMounts(otaDiffMounts(&d.Old), otaDiffMounts(&d.New), conf)
		return
	}
	if d.Old.InputMode == inputModeDirectory {
		d.Machos, err = diffMachosInMounts(d.Old.Mount, d.New.Mount, conf)
		return
	}
	return fmt.Errorf("diff: parseMachos: IPSW mode uses machosJob via the volume-major orchestrator")
}

// machoDiffConfig builds the mcmd.DiffConfig used by all macho diff paths.
// Shared by parseMachos (OTA/Directory) and machosJob (IPSW).
func (d *Diff) machoDiffConfig() *mcmd.DiffConfig {
	return &mcmd.DiffConfig{
		Markdown:   true,
		Color:      false,
		DiffTool:   "git",
		AllowList:  d.conf.AllowList,
		BlockList:  d.conf.BlockList,
		CStrings:   d.conf.CStrings,
		FuncStarts: d.conf.FuncStarts,
		Verbose:    d.conf.Verbose,
	}
}

// dscDiffConfig builds the mcmd.DiffConfig used for dyld_shared_cache image
// diffs. DSC suppresses only noisy cache-image load-command bytes; section
// names, sizes, content hashes, and semantic signals follow the same rules as
// every other Mach-O report.
func (d *Diff) dscDiffConfig() *mcmd.DiffConfig {
	conf := d.machoDiffConfig()
	conf.IgnoreLoadCommands = true
	return conf
}

// parseLaunchdPlists handles the OTA path (Directory mode is not supported
// for launchd). IPSW mode is handled by launchdJob via the volume-major
// orchestrator.
func (d *Diff) parseLaunchdPlists() error {
	if d.Old.InputMode == inputModeOTA {
		if err := d.ensureOTAPayloadFilesystems(); err != nil {
			return fmt.Errorf("diff: parseLaunchdPlists: %v", err)
		}
		oldConfig, err := launchdConfigFromRoots(otaLaunchdSearchRoots(&d.Old))
		if err != nil {
			return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'Old' launchd config: %v", err)
		}
		newConfig, err := launchdConfigFromRoots(otaLaunchdSearchRoots(&d.New))
		if err != nil {
			return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'New' launchd config: %v", err)
		}
		return d.applyLaunchdGitDiff(oldConfig, newConfig)
	}
	if d.Old.InputMode != inputModeIPSW {
		return fmt.Errorf("diff: parseLaunchdPlists: launchd diff is only supported for IPSW and OTA payload inputs")
	}
	return fmt.Errorf("diff: parseLaunchdPlists: IPSW mode uses launchdJob via the volume-major orchestrator")
}

// applyLaunchdGitDiff renders the git diff of the two extracted launchd
// __TEXT.__config strings into d.Launchd. Shared by parseLaunchdPlists
// (OTA) and launchdJob (IPSW).
func (d *Diff) applyLaunchdGitDiff(oldConfig, newConfig string) error {
	out, err := utils.GitDiff(
		oldConfig+"\n",
		newConfig+"\n",
		&utils.GitDiffConfig{Color: false, Tool: "git"})
	if err != nil {
		return err
	}
	if len(out) > 0 {
		d.Launchd = "```diff\n" + out + "\n```"
	}
	return nil
}

func (d *Diff) parseFirmwares() (err error) {
	conf := &mcmd.DiffConfig{
		Markdown:   true,
		Color:      false,
		DiffTool:   "git",
		AllowList:  d.conf.AllowList,
		BlockList:  d.conf.BlockList,
		CStrings:   d.conf.CStrings,
		FuncStarts: d.conf.FuncStarts,
		Verbose:    d.conf.Verbose,
	}
	if d.Old.InputMode == inputModeOTA {
		d.Firmwares, err = diffFirmwaresFromOTA(&d.Old, &d.New, conf)
		return
	}
	if d.Old.InputMode == inputModeDirectory {
		log.Warn("Firmware diff (--fw) not supported for directory inputs; skipping")
		return nil
	}
	d.Firmwares, err = mcmd.DiffFirmwares(d.Old.IPSWPath, d.New.IPSWPath, conf)
	return
}

func (d *Diff) parseIBoot() (err error) {
	d.IBoot = &IBootDiff{
		New:     make(map[string][]string),
		Removed: make(map[string][]string),
	}
	var oldIBoot, newIBoot *iboot.IBoot
	if d.Old.InputMode == inputModeOTA {
		oldIBoot, err = parseOTAIBoot(&d.Old)
		if err != nil {
			return fmt.Errorf("failed to get iBoot from 'Old' OTA: %v", err)
		}
		newIBoot, err = parseOTAIBoot(&d.New)
		if err != nil {
			return fmt.Errorf("failed to get iBoot from 'New' OTA: %v", err)
		}
	} else {
		tmpDIR, err := os.MkdirTemp("", "ipsw_extract_iboot")
		if err != nil {
			return fmt.Errorf("failed to create temporary directory to store im4ps: %v", err)
		}
		defer os.RemoveAll(tmpDIR)
		getIboot := func(ipswPath string) (*iboot.IBoot, error) {
			iBootIm4ps, err := utils.Unzip(ipswPath, tmpDIR, func(f *zip.File) bool {
				// return regexp.MustCompile(`iBSS.*\.im4p$`).MatchString(f.Name) || regexp.MustCompile(`iBoot\..*\.im4p$`).MatchString(f.Name)
				return regexp.MustCompile(`iBoot\..*\.im4p$`).MatchString(f.Name)
			})
			if err != nil {
				return nil, fmt.Errorf("failed to unzip iBoot im4p: %v", err)
			}
			im4p, err := img4.OpenPayload(iBootIm4ps[0])
			if err != nil {
				return nil, fmt.Errorf("failed to open im4p: %v", err)
			}
			return iboot.Parse(im4p.Data)
		}
		oldIBoot, err = getIboot(d.Old.IPSWPath)
		if err != nil {
			return fmt.Errorf("failed to get iBoot from 'Old' IPSW: %v", err)
		}
		newIBoot, err = getIboot(d.New.IPSWPath)
		if err != nil {
			return fmt.Errorf("failed to get iBoot from 'New' IPSW: %v", err)
		}
	}
	d.IBoot.Versions = []string{oldIBoot.Version, newIBoot.Version}
	for name, strs := range newIBoot.Strings {
		if _, ok := oldIBoot.Strings[name]; ok {
			for _, str := range strs {
				if len(str) < 10 {
					continue
				}
				found := false
				for _, oldStr := range oldIBoot.Strings[name] {
					if str == oldStr {
						found = true
						break
					}
				}
				if !found {
					d.IBoot.New[name] = append(d.IBoot.New[name], str)
				}
			}
		} else {
			for _, str := range strs {
				d.IBoot.New[name] = append(d.IBoot.New[name], str)
			}
		}
	}
	for name, strs := range oldIBoot.Strings {
		if _, ok := newIBoot.Strings[name]; ok {
			for _, str := range strs {
				if len(str) < 10 {
					continue
				}
				found := false
				for _, newStr := range newIBoot.Strings[name] {
					if str == newStr {
						found = true
						break
					}
				}
				if !found {
					d.IBoot.Removed[name] = append(d.IBoot.Removed[name], str)
				}
			}
		}
	}
	return nil
}

// parseFeatureFlags handles OTA and Directory input modes. IPSW mode is
// handled by featuresJob via the volume-major orchestrator.
func (d *Diff) parseFeatureFlags() error {
	var oldMounts, newMounts map[string]mount
	switch d.Old.InputMode {
	case inputModeOTA:
		if err := d.ensureOTAPayloadFilesystems(); err != nil {
			return err
		}
		oldMounts = otaDiffMounts(&d.Old)
		newMounts = otaDiffMounts(&d.New)
	case inputModeDirectory:
		oldMounts = d.Old.Mount
		newMounts = d.New.Mount
	default:
		return fmt.Errorf("diff: parseFeatureFlags: IPSW mode uses featuresJob via the volume-major orchestrator")
	}

	prevByVolume := make(map[string]map[string]string)
	nextByVolume := make(map[string]map[string]string)
	volumes, err := collectFeatureFlagsByVolume(oldMounts, prevByVolume, newMounts, nextByVolume)
	if err != nil {
		return err
	}
	out, err := buildPlistDiffByVolume(volumes, prevByVolume, nextByVolume)
	if err != nil {
		return err
	}
	d.Features = out
	return nil
}

// collectFeatureFlagsByVolume walks System/Library/FeatureFlags under every
// mount on both sides, populating per-volume plist maps. Returns the union
// of volume names in sorted order so callers can build a deterministic
// output.
func collectFeatureFlagsByVolume(oldMounts map[string]mount, prev map[string]map[string]string, newMounts map[string]mount, next map[string]map[string]string) ([]string, error) {
	names := unionMountNames(oldMounts, newMounts)
	for _, name := range names {
		if mnt, ok := oldMounts[name]; ok {
			prev[name] = make(map[string]string)
			if err := collectFeatureFlagsFromMount(mnt.MountPath, name, prev[name]); err != nil {
				return nil, err
			}
		}
		if mnt, ok := newMounts[name]; ok {
			next[name] = make(map[string]string)
			if err := collectFeatureFlagsFromMount(mnt.MountPath, name, next[name]); err != nil {
				return nil, err
			}
		}
	}
	return names, nil
}

// trackVolumeOnce appends label to volumes if not already present.
func trackVolumeOnce(volumes *[]string, label string) {
	if slices.Contains(*volumes, label) {
		return
	}
	*volumes = append(*volumes, label)
}

func removeVolume(volumes *[]string, label string) {
	out := (*volumes)[:0]
	for _, v := range *volumes {
		if v != label {
			out = append(out, v)
		}
	}
	*volumes = out
}

// buildPlistDiffByVolume builds the per-volume PlistDiff map. Empty
// per-volume diffs are omitted. Shared by featuresJob and locsJob.
func buildPlistDiffByVolume(volumes []string, prev, next map[string]map[string]string) (map[string]*PlistDiff, error) {
	out := make(map[string]*PlistDiff, len(volumes))
	for _, vol := range volumes {
		diff, err := buildPlistDiff(prev[vol], next[vol])
		if err != nil {
			return nil, err
		}
		if plistDiffHasContent(diff) {
			out[vol] = diff
		}
	}
	return out, nil
}

func plistDiffHasContent(d *PlistDiff) bool {
	return d != nil && (len(d.New) > 0 || len(d.Removed) > 0 || len(d.Updated) > 0)
}

// buildPlistDiff computes a PlistDiff from path-keyed old/new plist content
// maps. Shared by feature-flag and localization paths.
func buildPlistDiff(oldPlists, newPlists map[string]string) (*PlistDiff, error) {
	diff := &PlistDiff{
		New:     make(map[string]string),
		Updated: make(map[string]string),
	}

	prevFiles := make([]string, 0, len(oldPlists))
	for f := range oldPlists {
		prevFiles = append(prevFiles, f)
	}
	slices.Sort(prevFiles)

	nextFiles := make([]string, 0, len(newPlists))
	for f := range newPlists {
		nextFiles = append(nextFiles, f)
	}
	slices.Sort(nextFiles)

	newFiles := utils.Difference(nextFiles, prevFiles)
	diff.Removed = utils.Difference(prevFiles, nextFiles)

	for _, f2 := range nextFiles {
		dat2 := newPlists[f2]
		if slices.Contains(newFiles, f2) {
			diff.New[f2] = dat2
			continue
		}
		dat1, ok := oldPlists[f2]
		if !ok || strings.EqualFold(dat2, dat1) {
			continue
		}
		out, err := utils.GitDiff(dat1+"\n", dat2+"\n", &utils.GitDiffConfig{Color: false, Tool: "git"})
		if err != nil {
			return nil, err
		}
		if len(out) == 0 {
			continue
		}
		diff.Updated[f2] = "```diff\n" + out + "\n```\n"
	}

	return diff, nil
}

// parseFiles diffs file paths for OTA and Directory input modes. IPSW mode
// is handled by filesJob via the volume-major orchestrator.
func (d *Diff) parseFiles() error {
	if d.Old.InputMode == inputModeOTA {
		if err := d.ensureOTAPayloadFilesystems(); err != nil {
			return err
		}
		var err error
		d.Files, err = diffFilesInMounts(otaDiffMounts(&d.Old), otaDiffMounts(&d.New))
		return err
	}
	if d.Old.InputMode == inputModeDirectory {
		var err error
		d.Files, err = diffFilesInMounts(d.Old.Mount, d.New.Mount)
		return err
	}
	return fmt.Errorf("diff: parseFiles: IPSW mode uses filesJob via the volume-major orchestrator")
}

// openCacheStore initializes the persistent cache store for IPSW-mode runs
// and installs it on d via SetStore. Non-IPSW input modes are returned a nil
// cleanup so the per-orchestrator MemoryStore path stays in effect; failures
// to open the persistent store are logged and fall back to a temp-backed
// SQLiteStore via storage.OpenCacheStore.
func (d *Diff) openCacheStore() func() {
	if d.conf == nil {
		return nil
	}
	if d.Old.InputMode != inputModeIPSW || d.New.InputMode != inputModeIPSW {
		return nil
	}
	opts := storage.CacheOptions{
		OldInfo:  d.Old.Info,
		NewInfo:  d.New.Info,
		Dir:      d.conf.Cache.Dir,
		NoCache:  d.conf.Cache.NoCache,
		Clean:    d.conf.Cache.Clean,
		MaxBytes: d.conf.Cache.MaxBytes,
	}
	store, cleanup, err := storage.OpenCacheStore(opts)
	if err != nil {
		log.WithError(err).Warn("failed to open diff cache; falling back to in-memory store")
		return nil
	}
	if d.conf.Cache.NoCache {
		log.Debug("using temporary diff cache (--no-cache)")
	} else if path, ok := persistentCachePath(d.Old.Info, d.New.Info, d.conf.Cache.Dir); ok {
		log.Infof("using diff cache at %s", path)
	} else {
		log.Debug("using temporary diff cache (identity unavailable)")
	}
	d.SetStore(store)
	return func() {
		cleanup()
		d.SetStore(nil)
	}
}

// persistentCachePath returns the path OpenCacheStore would resolve for the
// (old, new) pair given the override directory, so callers can log the
// concrete path. ok=false when either Info lacks a BuildManifest and the
// cache must therefore fall back to a temp-backed store.
func persistentCachePath(oldInfo, newInfo *info.Info, dir string) (string, bool) {
	oldID, err := storage.IPSWCacheIdentity(oldInfo)
	if err != nil {
		return "", false
	}
	newID, err := storage.IPSWCacheIdentity(newInfo)
	if err != nil {
		return "", false
	}
	path, err := storage.ResolveCachePath(oldID, newID, dir)
	if err != nil {
		return "", false
	}
	return path, true
}
