package crashlog

import (
	"bytes"
	"compress/gzip"
	_ "embed"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"
	"time"

	"github.com/AlecAivazis/survey/v2"
	"github.com/AlecAivazis/survey/v2/terminal"
	"github.com/apex/log"
	"github.com/blacktop/arm64-cgo/disassemble"
	"github.com/blacktop/go-macho"
	"github.com/blacktop/go-macho/pkg/swift"
	"github.com/blacktop/go-macho/types"
	"github.com/blacktop/ipsw/internal/commands/dsc"
	"github.com/blacktop/ipsw/internal/commands/extract"
	"github.com/blacktop/ipsw/internal/demangle"
	"github.com/blacktop/ipsw/internal/magic"
	"github.com/blacktop/ipsw/internal/search"
	"github.com/blacktop/ipsw/internal/syms/server"
	"github.com/blacktop/ipsw/pkg/disass"
	"github.com/blacktop/ipsw/pkg/dyld"
	"github.com/blacktop/ipsw/pkg/info"
	"github.com/blacktop/ipsw/pkg/signature"
	"github.com/fatih/color"
	"github.com/go-viper/mapstructure/v2"
)

// REFERENCES:
//     - https://developer.apple.com/documentation/xcode/interpreting-the-json-format-of-a-crash-report
//     - https://github.com/zed-industries/zed/blob/main/crates/collab/src/api/ips_file.rs

// logTypeData is the crash-log type database (Apple bug_type -> name), gzipped
// from OSAnalytics' submissionConfig.plist. Last refreshed from iOS/macOS 27
// (24A5355q / 26A5353q). To update it from a newer IPSW, run
// pkg/crashlog/data/update.sh (see that script's header for the one-liner).
//
//go:embed data/log_type.gz
var logTypeData []byte

var colorTime = color.New(color.Bold, color.FgHiGreen).SprintFunc()
var colorError = color.New(color.Bold, color.FgHiRed).SprintFunc()
var colorAddr = color.New(color.Faint).SprintfFunc()
var colorBold = color.New(color.Bold).SprintfFunc()
var colorImage = color.New(color.Bold, color.FgHiMagenta).SprintfFunc()
var colorField = color.New(color.Bold, color.FgHiBlue).SprintFunc()

var ErrDone = errors.New("done")

type LogType struct {
	Name       string `json:"name"`
	Comment    string `json:"comment,omitempty"`
	Extention  string `json:"ext,omitempty"`
	EOS        bool   `json:"eOS,omitempty"`
	Gm         bool   `json:"gm,omitempty"`
	Legacy     bool   `json:"legacy,omitempty"`
	MacOS      bool   `json:"macOS,omitempty"`
	Disabled   bool   `json:"disabled,omitempty"`
	WatchSync  bool   `json:"watchSync,omitempty"`
	Limit      int    `json:"limit,omitempty"`
	Radar      string `json:"radar,omitempty"`
	Seed       bool   `json:"seed,omitempty"`
	Routing    string `json:"routing,omitempty"`
	Subrouting string `json:"subrouting,omitempty"`
}

// LogTypes from /System/Library/PrivateFrameworks/OSAnalytics.framework/Versions/A/Resources/submissionConfig.plist
type LogTypes map[string]LogType

type DB struct {
	LogTypes           LogTypes       `json:"log_types"`
	SubmissionParams   map[string]any `json:"submission_params"`
	WhitelistedDomains map[string]any `json:"whitelisted_domains"`
}

func GetLogTypes() (*LogTypes, error) {
	var db DB

	zr, err := gzip.NewReader(bytes.NewReader(logTypeData))
	if err != nil {
		return nil, err
	}
	defer zr.Close()

	if err := json.NewDecoder(zr).Decode(&db); err != nil {
		return nil, fmt.Errorf("failed unmarshaling ipsw_db data: %w", err)
	}

	return &db.LogTypes, nil
}

type Config struct {
	All           bool
	Running       bool
	Process       string
	Unslid        bool
	KernelSlide   uint64 // Custom KASLR slide to apply to kernel frames for live debugging
	DSCSlide      uint64 // Custom slide to apply to dyld_shared_cache frames for live debugging
	Demangle      bool
	Hex           bool
	Verbose       bool
	Peek          bool
	PeekCount     int // Number of instructions to show with --peek (default 5)
	PemDB         string
	SignaturesDir string
	ExtrasDir     string
	IDAScript     bool   // Generate IDAPython script to mark panic frames
	CrashlogPath  string // Path to the crashlog file (set automatically by OpenIPS)
}

type Ips struct {
	Header        IpsMetadata
	Payload       IPSPayload
	Jetsam        *Jetsam // populated for JetsamEvent (bug_type 298) reports
	Config        *Config
	KernelSymbols signature.SymbolMap // Symbols discovered while analyzing the kernelcache
}

type Platform int

const (
	PlatformMacOS            Platform = 1
	PlatformIOS              Platform = 2 // (includes iOS apps running under macOS on Apple silicon)
	PlatformTVOS             Platform = 3
	PlatformWatch            Platform = 4
	PlatformMacCatalyst      Platform = 6
	PlatformIOSSimulator     Platform = 7
	PlatformTVOSSimulator    Platform = 8
	PlatformWatchOSSimulator Platform = 9
)

func (p Platform) String() string {
	switch p {
	case PlatformMacOS:
		return "macOS"
	case PlatformIOS:
		return "iOS"
	case PlatformTVOS:
		return "tvOS"
	case PlatformWatch:
		return "watchOS"
	case PlatformMacCatalyst:
		return "Mac Catalyst"
	case PlatformIOSSimulator:
		return "iOS Simulator"
	case PlatformTVOSSimulator:
		return "tvOS Simulator"
	case PlatformWatchOSSimulator:
		return "watchOS Simulator"
	default:
		return "Unknown"
	}
}

type Timestamp time.Time

// timestampLayouts are tried in order. Apple has shipped several shapes
// (fractional seconds, ISO-8601) across report types; an unparseable timestamp
// must never fail the surrounding document — it leaves a zero time.
var timestampLayouts = []string{
	"2006-01-02 15:04:05.00 -0700",
	"2006-01-02 15:04:05 -0700",
	time.RFC3339Nano,
	time.RFC3339,
	"2006-01-02 15:04:05",
}

func (ts *Timestamp) UnmarshalJSON(b []byte) error {
	s := strings.Trim(string(b), "\"")
	if s == "null" || s == "" {
		return nil
	}
	for _, layout := range timestampLayouts {
		if t, err := time.Parse(layout, s); err == nil {
			*ts = Timestamp(t)
			return nil
		}
	}
	log.Debugf("crashlog: unrecognized timestamp %q; leaving zero time", s)
	return nil
}
func (r Timestamp) MarshalJSON() ([]byte, error) {
	return json.Marshal(time.Time(r))
}
func (r Timestamp) Format(s string) string {
	t := time.Time(r)
	return t.Format(s)
}

type IpsMetadata struct {
	Name             string    `json:"name,omitempty"`
	AppName          string    `json:"app_name,omitempty"`
	AppVersion       string    `json:"app_version,omitempty"`
	BugType          string    `json:"bug_type,omitempty"`
	BugTypeDesc      string    `json:"bug_type_desc,omitempty"`
	OsVersion        string    `json:"os_version,omitempty"`
	BundleID         string    `json:"bundleID,omitempty"`
	BuildVersion     string    `json:"build_version,omitempty"`
	IncidentID       string    `json:"incident_id,omitempty"`
	Platform         Platform  `json:"platform,omitempty"`
	Timestamp        Timestamp `json:"timestamp"`
	SliceUUID        string    `json:"slice_uuid,omitempty"`
	ShareWithAppDevs int       `json:"share_with_app_devs,omitempty"`
	IsFirstParty     int       `json:"is_first_party,omitempty"`
	RootsInstalled   int       `json:"roots_installed,omitempty"`
}

func (m IpsMetadata) Version() string {
	re := regexp.MustCompile(`(?P<version>[0-9.]+) \((?P<build>\w+)\)$`)
	matches := re.FindStringSubmatch(m.OsVersion)
	if len(matches) != 3 {
		return m.OsVersion
	}
	return matches[1]
}
func (m IpsMetadata) Build() string {
	re := regexp.MustCompile(`(?P<version>[0-9.]+) \((?P<build>\w+)\)$`)
	matches := re.FindStringSubmatch(m.OsVersion)
	if len(matches) != 3 {
		return m.OsVersion
	}
	return matches[2]
}

type MemoryStatus struct {
	BusyBufferCount int64 `json:"busyBufferCount,omitempty"`
	Compressions    int64 `json:"compressions,omitempty"`
	CompressorSize  int64 `json:"compressorSize,omitempty"`
	Decompressions  int64 `json:"decompressions,omitempty"`
	MemoryPages     struct {
		Active      int64 `json:"active,omitempty"`
		FileBacked  int64 `json:"fileBacked,omitempty"`
		Free        int64 `json:"free,omitempty"`
		Inactive    int64 `json:"inactive,omitempty"`
		Purgeable   int64 `json:"purgeable,omitempty"`
		Speculative int64 `json:"speculative,omitempty"`
		Throttled   int64 `json:"throttled,omitempty"`
		Wired       int64 `json:"wired,omitempty"`
	} `json:"memoryPages"`
	MemoryPressure        bool `json:"memoryPressure,omitempty"`
	MemoryPressureDetails struct {
		PagesReclaimed int64 `json:"pagesReclaimed,omitempty"`
		PagesWanted    int64 `json:"pagesWanted,omitempty"`
	} `json:"memoryPressureDetails"`
	PageSize int64 `json:"pageSize,omitempty"`
}

func (ms MemoryStatus) String() string {
	return fmt.Sprintf(
		"%s:\n"+
			"  %s: %d\n"+
			"  %s: %d\n"+
			"  %s: %d\n"+
			"  %s: %d\n"+
			"  %s:\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"  %s: %t\n"+
			"  %s:\n"+
			"    %s: %d\n"+
			"    %s: %d\n"+
			"  %s: %d\n",
		colorField("Memory Status"),
		colorField("Busy Buffer Count"), ms.BusyBufferCount,
		colorField("Compressions"), ms.Compressions,
		colorField("Compressor Size"), ms.CompressorSize,
		colorField("Decompressions"), ms.Decompressions,
		colorField("Memory Pages"),
		colorField("Active"), ms.MemoryPages.Active,
		colorField("File Backed"), ms.MemoryPages.FileBacked,
		colorField("Free"), ms.MemoryPages.Free,
		colorField("Inactive"), ms.MemoryPages.Inactive,
		colorField("Purgeable"), ms.MemoryPages.Purgeable,
		colorField("Speculative"), ms.MemoryPages.Speculative,
		colorField("Throttled"), ms.MemoryPages.Throttled,
		colorField("Wired"), ms.MemoryPages.Wired,
		colorField("Memory Pressure"), ms.MemoryPressure,
		colorField("Memory Pressure Details"),
		colorField("Pages Reclaimed"), ms.MemoryPressureDetails.PagesReclaimed,
		colorField("Pages Wanted"), ms.MemoryPressureDetails.PagesWanted,
		colorField("Page Size"), ms.PageSize,
	)
}

type Process struct {
	ID                  int            `json:"pid"`
	Name                string         `json:"procname"`
	TimesThrottled      int            `json:"timesThrottled"`
	TurnstileInfo       []string       `json:"turnstileInfo"`
	PageIns             int            `json:"pageIns"`
	WaitInfo            []string       `json:"waitInfo"`
	TimesDidThrottle    int            `json:"timesDidThrottle"`
	CopyOnWriteFaults   int            `json:"copyOnWriteFaults"`
	PageFaults          int            `json:"pageFaults"`
	UserTimeTask        float64        `json:"userTimeTask"`
	SystemTimeTask      float64        `json:"systemTimeTask"`
	Flags               []string       `json:"flags"`
	ResidentMemoryBytes int            `json:"residentMemoryBytes"`
	ThreadByID          map[int]Thread `json:"threadById,omitempty"`
}

type Thread struct {
	ID                 int          `json:"id"`
	Name               string       `json:"name,omitempty"`
	State              []string     `json:"state"`
	Continuation       []int        `json:"continuation,omitempty"`
	Queue              string       `json:"queue,omitempty"`
	DispatchQueueLabel string       `json:"dispatch_queue_label,omitempty"`
	SchedFlags         []string     `json:"schedFlags,omitempty"`
	BasePriority       int          `json:"basePriority"`
	UserFrames         []PanicFrame `json:"userFrames"`
	KernelFrames       []PanicFrame `json:"kernelFrames,omitempty"`
	WaitEvent          []float64    `json:"waitEvent,omitempty"`
	QosRequested       string       `json:"qosRequested,omitempty"`
	QosEffective       string       `json:"qosEffective,omitempty"`
	UserTime           float64      `json:"userTime"`
	UserUsec           int          `json:"user_usec"`
	SystemTime         float64      `json:"systemTime"`
	SystemUsec         int          `json:"system_usec"`
	SchedPriority      int          `json:"schedPriority"`
}

type BinaryImage struct {
	Arch   string `json:"arch,omitempty"`
	Base   uint64 `json:"base,omitempty"`
	Name   string `json:"name,omitempty"`
	Path   string `json:"path,omitempty"`
	Size   uint64 `json:"size,omitempty"`
	Source string `json:"source,omitempty"`
	UUID   string `json:"uuid,omitempty"`
	Slide  uint64 `json:"slide,omitempty"`
}

// FactorPackIDs accepts both the current string-list rollout encoding and the legacy object encoding.
type FactorPackIDs []string

func (ids *FactorPackIDs) UnmarshalJSON(data []byte) error {
	if bytes.Equal(data, []byte("null")) {
		*ids = nil
		return nil
	}

	var values []string
	arrayErr := json.Unmarshal(data, &values)
	if arrayErr == nil {
		*ids = values
		return nil
	}

	var legacyObject struct{}
	if err := json.Unmarshal(data, &legacyObject); err == nil {
		*ids = nil
		return nil
	}

	return arrayErr
}

func (bi *BinaryImage) UnmarshalJSON(b []byte) error {
	var raw []json.RawMessage
	if err := json.Unmarshal(b, &raw); err != nil {
		return err
	}
	if len(raw) < 3 { // a truncated/reshaped tuple must not panic on raw[1]/raw[2]
		return fmt.Errorf("binaryImage: expected >=3 elements, got %d", len(raw))
	}
	if err := json.Unmarshal(raw[0], &bi.UUID); err != nil {
		return err
	}
	if err := json.Unmarshal(raw[1], &bi.Base); err != nil {
		return err
	}
	if err := json.Unmarshal(raw[2], &bi.Source); err != nil {
		return err
	}
	switch bi.Source {
	case "P":
		bi.Source = "Process"
	case "S":
		bi.Source = "SharedCache"
	case "C":
		bi.Source = "SharedCacheLibrary"
	case "K":
		bi.Source = "Kernel"
	case "U":
		bi.Source = "KernelCache"
	case "T":
		bi.Source = "KernelTextExec"
	case "A":
		bi.Source = "Absolute"
	default:
		// An unknown source class (Apple adds one) must not fail the whole
		// report; keep the raw letter and treat it as a generic image.
		bi.Source = "Unknown(" + bi.Source + ")"
	}
	return nil
}

type OsVersion struct {
	ReleaseType string `json:"releaseType,omitempty"`
	Build       string `json:"build,omitempty"`
	Train       string `json:"train,omitempty"`
	IsEmbedded  bool   `json:"isEmbedded,omitempty"`
}

type BundleInfo struct {
	CFBundleIdentifier         string `json:"CFBundleIdentifier,omitempty"`
	CFBundleShortVersionString string `json:"CFBundleShortVersionString,omitempty"`
	CFBundleVersion            string `json:"CFBundleVersion,omitempty"`
	DTAppStoreToolsBuild       string `json:"DTAppStoreToolsBuild,omitempty"`
}

type StoreInfo struct {
	ItemID                            string `json:"itemID,omitempty"`
	StoreCohortMetadata               string `json:"storeCohortMetadata,omitempty"`
	DistributorID                     string `json:"distributorID,omitempty"`
	ApplicationVariant                string `json:"applicationVariant,omitempty"`
	DeviceIdentifierForVendor         string `json:"deviceIdentifierForVendor,omitempty"`
	SoftwareVersionExternalIdentifier string `json:"softwareVersionExternalIdentifier,omitempty"`
	ThirdParty                        bool   `json:"thirdParty,omitempty"`
}

type Exception struct {
	Codes    string `json:"codes,omitempty"`
	RawCodes []int  `json:"rawCodes,omitempty"`
	Message  string `json:"message,omitempty"`
	Signal   string `json:"signal,omitempty"`
	Type     string `json:"type,omitempty"`
	Subtype  string `json:"subtype,omitempty"`
}

func fmtAddr(val uint64) string {
	if val == 0 {
		return colorAddr("%#016x", val)
	}
	return fmt.Sprintf("%#016x", val)
}
func fmtAddrSmol(val uint64) string {
	if val == 0 {
		return colorAddr("%#08x", val)
	}
	return fmt.Sprintf("%#08x", val)
}

type Register struct {
	Value             uint64 `json:"value,omitempty"`
	SymbolLocation    uint64 `json:"symbolLocation,omitempty"`
	Symbol            string `json:"symbol,omitempty"`
	Description       string `json:"description,omitempty"`
	MatchesCrashFrame int    `json:"matchesCrashFrame,omitempty"`
}

func (r Register) String() string {
	var out strings.Builder
	out.WriteString(fmtAddr(r.Value))
	if r.SymbolLocation != 0 {
		out.WriteString(fmt.Sprintf(" %s", colorAddr("%#016x", r.SymbolLocation)))
	}
	if len(r.Symbol) > 0 {
		out.WriteString(fmt.Sprintf(" %s", colorImage(r.Symbol)))
	}
	if len(r.Description) > 0 {
		out.WriteString(" " + colorField(r.Description))
	}
	if r.MatchesCrashFrame != 0 {
		out.WriteString(colorBold(" matches_crash_frame=%d", r.MatchesCrashFrame))
	}
	return out.String()
}

type ThreadState struct {
	X      []Register `json:"x,omitempty"`
	Flavor string     `json:"flavor,omitempty"`
	LR     Register   `json:"lr"`
	CPSR   Register   `json:"cpsr"`
	FP     Register   `json:"fp"`
	SP     Register   `json:"sp"`
	ESR    Register   `json:"esr"`
	PC     Register   `json:"pc"`
	FAR    Register   `json:"far"`
}

func (s ThreadState) HasSymbols() bool {
	for _, reg := range s.X {
		if len(reg.Symbol) > 0 {
			return true
		}
	}
	if len(s.LR.Symbol) > 0 {
		return true
	}
	if len(s.FP.Symbol) > 0 {
		return true
	}
	if len(s.SP.Symbol) > 0 {
		return true
	}
	if len(s.PC.Symbol) > 0 {
		return true
	}
	if len(s.ESR.Symbol) > 0 {
		return true
	}
	return false
}

func (s ThreadState) String() string {
	if s.HasSymbols() {
		var out strings.Builder
		for i, reg := range s.X {
			out.WriteString(fmt.Sprintf("  %3s: %s\n", fmt.Sprintf("x%d", i), reg))
		}
		out.WriteString(fmt.Sprintf("   fp: %s\n", s.FP))
		out.WriteString(fmt.Sprintf("   lr: %s\n", s.LR))
		out.WriteString(fmt.Sprintf("   sp: %s\n", s.SP))
		out.WriteString(fmt.Sprintf("   pc: %s\n", s.PC))
		out.WriteString(fmt.Sprintf("  far: %s\n", s.FAR))
		out.WriteString(fmt.Sprintf(" cpsr: %s\n", s.CPSR))
		out.WriteString(fmt.Sprintf("  esr: %s\n", s.ESR))
		return out.String()
	} else {
		// xv guards against a short/absent register file (non-arm64 thread
		// state, truncated report, future flavor) — never index s.X by a literal.
		xv := func(i int) uint64 {
			if i < len(s.X) {
				return s.X[i].Value
			}
			return 0
		}
		return fmt.Sprintf(
			"    x0: %s   x1: %s   x2: %s   x3: %s\n"+
				"    x4: %s   x5: %s   x6: %s   x7: %s\n"+
				"    x8: %s   x9: %s  x10: %s  x11: %s\n"+
				"   x12: %s  x13: %s  x14: %s  x15: %s\n"+
				"   x16: %s  x17: %s  x18: %s  x19: %s\n"+
				"   x20: %s  x21: %s  x22: %s  x23: %s\n"+
				"   x24: %s  x25: %s  x26: %s  x27: %s\n"+
				"   x28: %s   fp: %s   lr: %s\n"+
				"    sp: %s   pc: %s cpsr: %s\n"+
				"   esr: %s %s\n",
			fmtAddr(xv(0)), fmtAddr(xv(1)), fmtAddr(xv(2)), fmtAddr(xv(3)),
			fmtAddr(xv(4)), fmtAddr(xv(5)), fmtAddr(xv(6)), fmtAddr(xv(7)),
			fmtAddr(xv(8)), fmtAddr(xv(9)), fmtAddr(xv(10)), fmtAddr(xv(11)),
			fmtAddr(xv(12)), fmtAddr(xv(13)), fmtAddr(xv(14)), fmtAddr(xv(15)),
			fmtAddr(xv(16)), fmtAddr(xv(17)), fmtAddr(xv(18)), fmtAddr(xv(19)),
			fmtAddr(xv(20)), fmtAddr(xv(21)), fmtAddr(xv(22)), fmtAddr(xv(23)),
			fmtAddr(xv(24)), fmtAddr(xv(25)), fmtAddr(xv(26)), fmtAddr(xv(27)),
			fmtAddr(xv(28)), fmtAddr(s.FP.Value), fmtAddr(s.LR.Value),
			fmtAddr(s.SP.Value), fmtAddr(s.PC.Value), fmtAddrSmol(s.CPSR.Value),
			fmtAddrSmol(s.ESR.Value), colorField(s.ESR.Description))
	}
}

type UserThread struct {
	Frames      []Frame     `json:"frames,omitempty"`
	ID          int         `json:"id,omitempty"`
	Name        string      `json:"name,omitempty"`
	Queue       string      `json:"queue,omitempty"`
	ThreadState ThreadState `json:"threadState"`
	Triggered   bool        `json:"triggered,omitempty"`
}

type Frame struct {
	ImageIndex     uint64 `json:"imageIndex,omitempty"`
	ImageOffset    uint64 `json:"imageOffset,omitempty"`
	Symbol         string `json:"symbol,omitempty"`
	SymbolLocation uint64 `json:"symbolLocation,omitempty"`
	Slide          uint64 `json:"slide,omitempty"`
}

type PanicFrame struct {
	ImageIndex     uint64 `json:"imageIndex,omitempty"`
	ImageName      string `json:"imageName,omitempty"`
	ImageOffset    uint64 `json:"imageOffset,omitempty"`
	Symbol         string `json:"symbol,omitempty"`
	SymbolLocation uint64 `json:"symbolLocation,omitempty"`
	Slide          uint64 `json:"slide,omitempty"`
	// PeekBytes holds ARM64 instructions around the frame address for --peek disassembly
	PeekBytes []byte `json:"-"`
	// PeekAddr is the starting address of PeekBytes
	PeekAddr uint64 `json:"-"`
	// PeekFrameIdx is the index of the frame instruction within PeekBytes (in instructions, not bytes)
	PeekFrameIdx int `json:"-"`
	// PeekSymbols maps addresses to symbol names for enriching branch target disassembly
	PeekSymbols map[uint64]string `json:"-"`
}

func (pf *PanicFrame) UnmarshalJSON(b []byte) error {
	// Frames are [imageIndex, offset] today, but tolerate trailing elements
	// Apple may append and offsets sent as quoted hex strings.
	var s []json.RawMessage
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	if len(s) < 2 {
		return fmt.Errorf("panicFrame: expected >=2 elements, got %d", len(s))
	}
	idx := jsonUint(s[0])
	*pf = PanicFrame{
		ImageIndex:  idx,
		ImageName:   fmt.Sprintf("image_%d", idx),
		ImageOffset: jsonUint(s[1]),
	}
	return nil
}

// jsonUint decodes a JSON number or a quoted (possibly 0x-prefixed) string into
// a uint64, returning 0 on anything unparseable.
func jsonUint(raw json.RawMessage) uint64 {
	var n uint64
	if err := json.Unmarshal(raw, &n); err == nil {
		return n
	}
	var str string
	if err := json.Unmarshal(raw, &str); err == nil {
		if v, err := strconv.ParseUint(strings.TrimSpace(str), 0, 64); err == nil {
			return v
		}
	}
	return 0
}

type Termination struct {
	ByPid     int      `json:"byPid,omitempty"`
	ByProc    string   `json:"byProc,omitempty"`
	Code      int      `json:"code,omitempty"`
	Flags     int      `json:"flags,omitempty"` // https://opensource.apple.com/source/xnu/xnu-3789.21.4/bsd/sys/reason.h.auto.html
	Indicator string   `json:"indicator,omitempty"`
	Namespace string   `json:"namespace,omitempty"`
	Details   []string `json:"details,omitempty"` // WATCHDOG reports explain the timeout here
}

type PostSampleVMStats struct {
	Faults                             int `json:"faults,omitempty"`
	ActiveCount                        int `json:"active_count,omitempty"`
	CompressorPageCount                int `json:"compressorPageCount,omitempty"`
	TotalUncompressedPagesInCompressor int `json:"total_uncompressed_pages_in_compressor,omitempty"`
	Hits                               int `json:"hits,omitempty"`
	Decompressions                     int `json:"decompressions,omitempty"`
	Swapouts                           int `json:"swapouts,omitempty"`
	WireCount                          int `json:"wire_count,omitempty"`
	Lookups                            int `json:"lookups,omitempty"`
	Purges                             int `json:"purges,omitempty"`
	Pageouts                           int `json:"pageouts,omitempty"`
	Pageins                            int `json:"pageins,omitempty"`
	ExternalPageCount                  int `json:"external_page_count,omitempty"`
	ZeroFillCount                      int `json:"zero_fill_count,omitempty"`
	FreeCount                          int `json:"free_count,omitempty"`
	CowFaults                          int `json:"cow_faults,omitempty"`
	SpeculativeCount                   int `json:"speculative_count,omitempty"`
	Swapins                            int `json:"swapins,omitempty"`
	Compressions                       int `json:"compressions,omitempty"`
	Reactivations                      int `json:"reactivations,omitempty"`
	InactiveCount                      int `json:"inactive_count,omitempty"`
	ThrottledCount                     int `json:"throttled_count,omitempty"`
	PurgeableCount                     int `json:"purgeable_count,omitempty"`
	InternalPageCount                  int `json:"internal_page_count,omitempty"`
}

type AdditionalDetails struct {
	StackshotDurationOuterNsec int `json:"stackshot_duration_outer_nsec,omitempty"`
	StackshotInFlags           int `json:"stackshot_in_flags,omitempty"`
	StackshotThreadsCount      int `json:"stackshot_threads_count,omitempty"`
	StackshotDurationPriorNsec int `json:"stackshot_duration_prior_nsec,omitempty"`
	StackshotSizeEstimateAdj   int `json:"stackshot_size_estimate_adj,omitempty"`
	StackshotOutFlags          int `json:"stackshot_out_flags,omitempty"`
	StackshotTasksCount        int `json:"stackshot_tasks_count,omitempty"`
	StackshotInPid             int `json:"stackshot_in_pid,omitempty"`
	StackshotSizeEstimate      int `json:"stackshot_size_estimate,omitempty"`
	StackshotTries             int `json:"stackshot_tries,omitempty"`
	StackshotDurationNsec      int `json:"stackshot_duration_nsec,omitempty"`
	SystemStateFlags           int `json:"system_state_flags,omitempty"`
}

type IPSPayload struct {
	/* Kernelspace Fields */
	Build            string          `json:"build,omitempty"`
	Product          string          `json:"product,omitempty"`
	SocID            string          `json:"socId,omitempty"`
	Kernel           string          `json:"kernel,omitempty"`
	Incident         string          `json:"incident,omitempty"`
	CrashReporterKey string          `json:"crashReporterKey,omitempty"`
	Date             string          `json:"date,omitempty"`
	PanicString      string          `json:"panicString,omitempty"`
	MacOSPanicString string          `json:"macOSPanicString,omitempty"`
	PanicFlags       string          `json:"panicFlags,omitempty"`
	OtherString      string          `json:"otherString,omitempty"`
	MemoryStatus     MemoryStatus    `json:"memoryStatus"`
	ProcessByPid     map[int]Process `json:"processByPid,omitempty"`
	BinaryImages     []BinaryImage   `json:"binaryImages,omitempty"`
	Notes            []string        `json:"notes,omitempty"`
	/* Userspace Fields */
	Asi map[string][]string `json:"asi,omitempty"` // Additional application-specific logging. The properties of this object include an array of log strings.
	// For more information, see Diagnostic messages. This appears in a translated report under Application Specific Information.
	// https://developer.apple.com/documentation/xcode/examining-the-fields-in-a-crash-report#Diagnostic-messages
	IsCorpse              any        `json:"isCorpse,omitempty"`
	IsNonFatal            string     `json:"isNonFatal,omitempty"`
	IsSimulated           string     `json:"isSimulated,omitempty"`
	Uptime                int        `json:"uptime,omitempty"`
	Translated            bool       `json:"translated,omitempty"`
	ProcName              string     `json:"procName,omitempty"`
	ProcPath              string     `json:"procPath,omitempty"`
	ProcRole              string     `json:"procRole,omitempty"`
	ProcLaunch            string     `json:"procLaunch,omitempty"`
	ProcStartAbsTime      int64      `json:"procStartAbsTime,omitempty"`
	ProcExitAbsTime       int64      `json:"procExitAbsTime,omitempty"`
	UserID                int        `json:"userID,omitempty"`
	DeployVersion         int        `json:"deployVersion,omitempty"`
	ModelCode             string     `json:"modelCode,omitempty"`
	CoalitionID           int        `json:"coalitionID,omitempty"`
	OsVersion             OsVersion  `json:"osVersion"`
	CaptureTime           string     `json:"captureTime,omitempty"`
	PID                   int        `json:"pid,omitempty"`
	CPUType               string     `json:"cpuType,omitempty"`
	RootsInstalled        int        `json:"roots_installed,omitempty"`
	BugType               string     `json:"bug_type,omitempty"`
	BundleInfo            BundleInfo `json:"bundleInfo"`
	StoreInfo             StoreInfo  `json:"storeInfo"`
	ParentProc            string     `json:"parentProc,omitempty"`
	ParentPid             int        `json:"parentPid,omitempty"`
	CoalitionName         string     `json:"coalitionName,omitempty"`
	LockdownMode          int        `json:"ldm,omitempty"`
	WasUnlockedSinceBoot  int        `json:"wasUnlockedSinceBoot,omitempty"`
	IsLocked              int        `json:"isLocked,omitempty"`
	InstructionByteStream struct {
		BeforePC string `json:"beforePC,omitempty"`
		AtPC     string `json:"atPC,omitempty"`
	} `json:"instructionByteStream"`
	CodeSigningID                 string       `json:"codeSigningID,omitempty"`
	CodeSigningTeamID             string       `json:"codeSigningTeamID,omitempty"`
	CodeSigningFlags              int          `json:"codeSigningFlags,omitempty"`
	CodeSigningValidationCategory int          `json:"codeSigningValidationCategory,omitempty"`
	CodeSigningTrustLevel         int          `json:"codeSigningTrustLevel,omitempty"`
	CodeSigningMonitor            int          `json:"codeSigningMonitor,omitempty"`
	ThrottleTimeout               int64        `json:"throttleTimeout,omitempty"`
	BasebandVersion               string       `json:"basebandVersion,omitempty"`
	Exception                     any          `json:"exception,omitempty"`
	LastExceptionBacktrace        []Frame      `json:"lastExceptionBacktrace,omitempty"`
	FaultingThread                int          `json:"faultingThread,omitempty"`
	Threads                       []UserThread `json:"threads,omitempty"`
	UsedImages                    []struct {
		Arch   string `json:"arch,omitempty"`
		Base   uint64 `json:"base,omitempty"`
		Name   string `json:"name,omitempty"`
		Path   string `json:"path,omitempty"`
		Size   uint64 `json:"size,omitempty"`
		Source string `json:"source,omitempty"`
		UUID   string `json:"uuid,omitempty"`
	} `json:"usedImages,omitempty"`
	SharedCache struct {
		Base uint64 `json:"base,omitempty"`
		Size uint64 `json:"size,omitempty"`
		UUID string `json:"uuid,omitempty"`
	} `json:"sharedCache"`
	LegacyInfo struct {
		ThreadTriggered struct {
			Queue string `json:"queue,omitempty"`
		} `json:"threadTriggered"`
	} `json:"legacyInfo"`
	TrialInfo struct {
		Rollouts []struct {
			RolloutID     string        `json:"rolloutId,omitempty"`
			FactorPackIds FactorPackIDs `json:"factorPackIds"`
			DeploymentID  int           `json:"deploymentId,omitempty"`
		} `json:"rollouts,omitempty"`
		Experiments []struct {
			TreatmentID  string `json:"treatmentId,omitempty"`
			ExperimentID string `json:"experimentId,omitempty"`
			DeploymentID int    `json:"deploymentId,omitempty"`
		} `json:"experiments,omitempty"`
	} `json:"trialInfo"`
	DTAppStoreToolsBuild string      `json:"DTAppStoreToolsBuild,omitempty"`
	Version              int         `json:"version,omitempty"`
	VMSummary            string      `json:"vmSummary,omitempty"`
	VmRegionInfo         string      `json:"vmregioninfo,omitempty"`
	Termination          Termination `json:"termination"`

	AbsoluteTime      int               `json:"absoluteTime,omitempty"`
	PostSampleVMStats PostSampleVMStats `json:"postSampleVMStats"`
	AdditionalDetails AdditionalDetails `json:"additionalDetails"`

	// SystemWatchdogCrash (409) fields. Its processByPid/binaryImages live under
	// a nested "stackshot" object (not at the payload top level); OpenIPS promotes
	// them so the panic-style process renderer works.
	ReportNotes          []string `json:"reportNotes,omitempty"`
	DisplayState         string   `json:"displayState,omitempty"`
	ThermalPressureLevel string   `json:"thermalPressureLevel,omitempty"`
	TrmStatus            int      `json:"trmStatus,omitempty"`
	Stackshot            struct {
		ProcessByPid map[int]Process `json:"processByPid,omitempty"`
		BinaryImages []BinaryImage   `json:"binaryImages,omitempty"`
	} `json:"stackshot"`

	panic210 *Panic210
}

func ParseHeader(in string) (hdr *IpsMetadata, err error) {
	f, err := os.Open(in)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	if err := json.NewDecoder(f).Decode(&hdr); err != nil {
		return nil, fmt.Errorf("failed to decode JSON header (possibly unsupported .ips format): %w", err)
	}

	db, err := GetLogTypes()
	if err != nil {
		return nil, fmt.Errorf("failed to get log types database: %w", err)
	}

	if bt, ok := (*db)[hdr.BugType]; ok {
		hdr.BugTypeDesc = bt.Name
		if len(bt.Comment) > 0 {
			hdr.BugTypeDesc = hdr.BugType + " (" + bt.Comment + ")"
		}
	}

	return hdr, nil
}

func OpenIPS(in string, conf *Config) (*Ips, error) {
	ips := Ips{Config: conf}
	// Store the crashlog path for later use (e.g., IDA script generation)
	conf.CrashlogPath = in

	f, err := os.Open(in)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	// parse multi-line JSON
	dec := json.NewDecoder(f)
	if err := dec.Decode(&ips.Header); err != nil {
		return nil, err
	}

	if ips.Header.BugType == "298" { // JetsamEvent has a distinct payload shape
		if err := dec.Decode(&ips.Jetsam); err != nil {
			return nil, fmt.Errorf("failed to decode JetsamEvent payload: %w", err)
		}
	} else {
		if err := dec.Decode(&ips.Payload); err != nil {
			return nil, err
		}
		if len(ips.Payload.Product) == 0 {
			ips.Payload.Product = ips.Payload.ModelCode
		}
		// SystemWatchdogCrash (409) nests its process/image tables under
		// "stackshot"; promote them so the shared process renderer can use them.
		if len(ips.Payload.ProcessByPid) == 0 && len(ips.Payload.Stackshot.ProcessByPid) > 0 {
			ips.Payload.ProcessByPid = ips.Payload.Stackshot.ProcessByPid
			ips.Payload.BinaryImages = ips.Payload.Stackshot.BinaryImages
		}
	}

	db, err := GetLogTypes()
	if err != nil {
		return nil, fmt.Errorf("failed to get log types database: %w", err)
	}

	if bt, ok := (*db)[ips.Header.BugType]; ok {
		ips.Header.BugTypeDesc = bt.Name
		if len(bt.Comment) > 0 {
			ips.Header.BugTypeDesc = ips.Header.BugType + " (" + bt.Comment + ")"
		}
	}

	return &ips, nil
}

func demangleSym(do bool, in string) string {
	if do {
		if strings.HasPrefix(in, "__Z") || strings.HasPrefix(in, "_Z") {
			return demangle.Do(in, false, false)
		}
		if swift.IsMangled(in) {
			in, _ = swift.Demangle(in)
		}
	}
	return in
}

// peekBytesReader is an interface for reading bytes at a virtual address
type peekBytesReader interface {
	ReadAtAddr(p []byte, addr uint64) (n int, err error)
}

// readPeekBytes reads ARM64 instructions around a frame address for --peek disassembly.
// count is the total number of instructions to show (centered on frame).
// funcStart is the function's start address (0 if unknown) - used to avoid reading before the function.
// Returns the bytes, the starting address, and the index of the frame instruction within the buffer.
//
// Centering logic:
//   - odd count (e.g. 5): equal before/after (2 before, frame, 2 after)
//   - even count (e.g. 4): fewer before (1 before, frame, 2 after)
//
// If frame is at/near function start, we shift instructions after to compensate.
func readPeekBytes(m peekBytesReader, frameAddr uint64, count int, funcStart, funcEnd uint64) ([]byte, uint64, int) {
	const instrSize = 4 // ARM64 instruction size

	if count < 1 {
		count = 1
	}

	// Calculate ideal number of instructions before/after
	// For odd count: equal split (5 -> 2 before, 1 frame, 2 after)
	// For even count: fewer before (4 -> 1 before, 1 frame, 2 after)
	numBefore := (count - 1) / 2
	numAfter := count - 1 - numBefore

	// Clamp numBefore if we'd read before function start
	if funcStart > 0 && frameAddr >= funcStart {
		maxBefore := int((frameAddr - funcStart) / instrSize)
		if numBefore > maxBefore {
			// Shift extra instructions to after
			extra := numBefore - maxBefore
			numBefore = maxBefore
			numAfter += extra
		}
	}

	// Also ensure we don't underflow the start address
	maxPossibleBefore := int(frameAddr / instrSize)
	if numBefore > maxPossibleBefore {
		extra := numBefore - maxPossibleBefore
		numBefore = maxPossibleBefore
		numAfter += extra
	}

	// Clamp numAfter if we'd read past function end
	if funcEnd > 0 && funcEnd > frameAddr {
		// funcEnd points to the first byte AFTER the function, so last valid instr starts at funcEnd-instrSize
		maxAfter := max(0, int((funcEnd-frameAddr)/instrSize)-1)
		if numAfter > maxAfter {
			numAfter = maxAfter
		}
	}

	totalInstrs := numBefore + 1 + numAfter
	startAddr := frameAddr - uint64(numBefore*instrSize)

	// Read the bytes
	buf := make([]byte, totalInstrs*instrSize)
	n, err := m.ReadAtAddr(buf, startAddr)
	if err != nil || n < len(buf) {
		return nil, 0, 0
	}
	return buf, startAddr, numBefore
}

// symbolLookup is an interface for looking up symbols by address
type symbolLookup interface {
	FindAddressSymbols(addr uint64) ([]macho.Symbol, error)
}

// extractPeekSymbols extracts symbol names for branch targets in peek bytes.
// peekAddr is the address to disassemble at (slid for user frames).
// slide is subtracted from branch targets before symbol lookup (for user frames with slide).
// The returned map uses slid addresses as keys to match formatPeekDisassembly lookups.
func extractPeekSymbols(peekBytes []byte, peekAddr uint64, slide uint64, lookup symbolLookup) map[uint64]string {
	if len(peekBytes) == 0 || lookup == nil {
		return nil
	}

	instructions, err := disassemble.GetInstructions(peekAddr, peekBytes)
	if err != nil {
		return nil
	}

	result := make(map[uint64]string)
	for _, block := range instructions.Blocks() {
		for _, instr := range block {
			// Check for branch instructions (B, BL)
			if instr.Operation == disassemble.ARM64_BL || instr.Operation == disassemble.ARM64_B {
				for _, operand := range instr.Operands {
					if operand.Class == disassemble.LABEL {
						target := uint64(operand.Immediate) // slid address
						if _, exists := result[target]; !exists {
							// Unslide target for symbol lookup (macho has unslid addresses)
							lookupAddr := target
							if slide > 0 && target >= slide {
								lookupAddr = target - slide
							}
							if syms, err := lookup.FindAddressSymbols(lookupAddr); err == nil && len(syms) > 0 {
								// Store with slid key for lookup in formatPeekDisassembly
								result[target] = syms[0].Name
							}
						}
						break
					}
				}
			}
		}
	}

	if len(result) == 0 {
		return nil
	}
	return result
}

// formatPeekDisassembly formats the peek bytes as disassembly output.
// peekFrameIdx is the index of the frame instruction within peekBytes (in instructions, not bytes).
// peekAddr is the start address of peekBytes.
// slide is used to adjust displayed addresses if unslid is true.
// customSlide is added to kernel frame addresses for live debugging (--kc-slide flag).
// dscSlide is added to DSC frame addresses for live debugging (--dsc-slide flag).
// isDSCFrame indicates if this is a dyld_shared_cache frame (Source == "SharedCache" or "SharedCacheLibrary").
// peekSymbols optionally maps addresses to symbol names for enriching branch targets.
// doMangle indicates whether to demangle symbol names.
//
// NOTE: For kernel frames, peekAddr is already an unslid address (BinaryImage.Base + ImageOffset).
// For user frames, peekAddr is a slid address. The unslid flag should only affect user frames.
func formatPeekDisassembly(peekBytes []byte, peekAddr uint64, peekFrameIdx int, slide, customSlide, dscSlide uint64, isDSCFrame, unslid bool, peekSymbols map[uint64]string, doMangle bool) string {
	if len(peekBytes) == 0 {
		return ""
	}

	instructions, err := disassemble.GetInstructions(peekAddr, peekBytes)
	if err != nil {
		return ""
	}

	// Detect if this is a kernel frame by checking if peekAddr is in kernel address space.
	// Kernel addresses are in high memory (0xfffffff0_00000000 and above).
	isKernelFrame := peekAddr >= 0xfffffff000000000

	var out strings.Builder
	instrIdx := 0
	for _, block := range instructions.Blocks() {
		for _, instr := range block {
			// Determine display address
			displayAddr := instr.Address

			if isKernelFrame && customSlide != 0 {
				// Apply custom slide to kernel frames for live debugging
				displayAddr += customSlide
			} else if isDSCFrame && dscSlide != 0 {
				// Apply DSC slide to shared cache frames for live debugging
				displayAddr += dscSlide
			} else if !isKernelFrame && unslid && slide != 0 && displayAddr >= slide {
				// Only apply unslide logic to user frames (not kernel frames)
				displayAddr -= slide
			}

			// Mark the frame instruction with --> using the frame index
			pad := "          "
			if instrIdx == peekFrameIdx {
				pad = "      --> "
			}

			opStr := strings.TrimSpace(strings.TrimPrefix(instr.String(), instr.Operation.String()))

			// Enrich branch instructions (B/BL) with symbol names if available
			comment := ""
			if (instr.Operation == disassemble.ARM64_BL || instr.Operation == disassemble.ARM64_B) && len(peekSymbols) > 0 {
				for _, operand := range instr.Operands {
					if operand.Class == disassemble.LABEL {
						target := uint64(operand.Immediate)
						if symName, ok := peekSymbols[target]; ok {
							display := symName
							if doMangle {
								display = demangle.Do(symName, false, false)
							}
							comment = fmt.Sprintf(" ; %s", display)
						}
						break
					}
				}
			}

			out.WriteString(fmt.Sprintf("%s%s:  %s   %s %s%s\n",
				pad,
				colorAddr("%#08x", displayAddr),
				disassemble.GetOpCodeByteString(instr.Raw),
				colorImage("%-7s", instr.Operation),
				disass.ColorOperands(opStr),
				colorField(comment),
			))
			instrIdx++
		}
	}
	return out.String()
}

// isDSCFrame returns true if the frame is from dyld_shared_cache (Source == "SharedCache" or "SharedCacheLibrary").
func (i *Ips) isDSCFrame(frame PanicFrame) bool {
	if int(frame.ImageIndex) < len(i.Payload.BinaryImages) {
		src := i.Payload.BinaryImages[frame.ImageIndex].Source
		return src == "SharedCache" || src == "SharedCacheLibrary"
	}
	return false
}

// panicFrameAddr computes the display address for a panic frame.
// After Symbolicate210: ImageOffset already has Base added:
//   - User frames: Base is slid, so ImageOffset is the slid runtime address
//   - Kernel frames: Base + raw offset = unslid address (kernel frames are already KASLR-unslid in crashlog JSON)
//
// The --unslide flag subtracts the slide to show unslid addresses (only affects user frames).
// The --slide flag adds a custom KASLR slide to kernel frames for live debugging.
// The --dsc-slide flag adds a custom slide to dyld_shared_cache frames for live debugging.
// Returns: (address, slide, wasSlid)
func (i *Ips) panicFrameAddr(frame PanicFrame) (addr, slide uint64, wasSlid bool) {
	// After symbolication, ImageOffset = BinaryImage.Base + raw offset from JSON
	// (Symbolicate210 does: frame.ImageOffset += BinaryImage.Base)
	addr = frame.ImageOffset

	// Get slide - frame.Slide is set during Symbolicate210 from BinaryImage.Slide
	if frame.Slide != 0 {
		slide = frame.Slide
	} else if int(frame.ImageIndex) < len(i.Payload.BinaryImages) {
		slide = i.Payload.BinaryImages[frame.ImageIndex].Slide
	}

	// Detect frame source type
	var isKernel, isDSC bool
	if int(frame.ImageIndex) < len(i.Payload.BinaryImages) {
		src := i.Payload.BinaryImages[frame.ImageIndex].Source
		switch src {
		case "Kernel", "KernelCache", "KernelTextExec":
			isKernel = true
		case "SharedCache", "SharedCacheLibrary":
			isDSC = true
		}
	}

	// Apply custom slides for live debugging:
	// - Kernel frames are stored unslid, so we ADD the custom slide to get runtime addresses
	// - DSC frames are stored with the static cache address, ADD custom slide to get runtime addresses
	if isKernel && i.Config.KernelSlide != 0 {
		addr += i.Config.KernelSlide
		slide = i.Config.KernelSlide
		wasSlid = true
	} else if isDSC && i.Config.DSCSlide != 0 {
		addr += i.Config.DSCSlide
		slide = i.Config.DSCSlide
		wasSlid = true
	} else if i.Config.Unslid {
		// Unslide user frames (kernel frames are already unslid, so skip them)
		if !isKernel && slide != 0 && addr >= slide {
			addr -= slide
		}
	}
	return addr, slide, wasSlid
}

// crashFrameAddr computes the display address and image name for a 309/"Crash"
// report frame.
//
// The raw address is the slid runtime address recorded in the crash:
// UsedImages[ImageIndex].Base + ImageOffset. Frames whose image lives inside the
// dyld_shared_cache (base within [sharedCache.base, sharedCache.base+size)) are
// rebased for static analysis when --dsc-slide N is set:
//
//	N + (runtime - sharedCache.base)
//
// i.e. the cache is treated as if loaded at base N (e.g. the base your static
// disassembler loaded the DSC at). Pass 0x180000000 for canonical macOS arm64
// cache vmaddrs. Detection is by address range, not UsedImages.Source: real 309
// reports tag cache-resident dylibs with source "P", so the source string is
// unreliable here. --unslide still relies on the frame's recorded slide (absent
// in 309 reports, so it is a no-op there). Returns the display address, the image
// name, and a human-readable note describing any remap.
func (i *Ips) crashFrameAddr(f Frame) (addr uint64, name, slideInfo string) {
	if f.ImageIndex >= uint64(len(i.Payload.UsedImages)) {
		return f.ImageOffset, fmt.Sprintf("image_%d", f.ImageIndex), ""
	}
	img := i.Payload.UsedImages[f.ImageIndex]
	addr = img.Base + f.ImageOffset

	// Some reports (e.g. 308 ExcUserFault) ship compact usedImages with only
	// base/source/uuid; fall back to a meaningful name instead of a blank cell.
	name = img.Name
	if name == "" {
		switch {
		case img.Path != "":
			name = filepath.Base(img.Path)
		case img.Source == "S" || img.Source == "C":
			name = "dyld_shared_cache"
		case img.Source == "P" && i.Payload.ProcName != "":
			name = i.Payload.ProcName
		case len(img.UUID) >= 8:
			name = "<" + img.UUID[:8] + ">"
		default:
			name = fmt.Sprintf("image_%d", f.ImageIndex)
		}
	}

	// A frame is in the shared cache if it sits inside the report's sharedCache
	// range, or its image is sourced from the cache (308 carries no sharedCache
	// block, so range-checking alone misses its source "S"/"C" frames).
	sc := i.Payload.SharedCache
	isDSC := (sc.Size > 0 && img.Base >= sc.Base && img.Base < sc.Base+sc.Size) || img.Source == "S" || img.Source == "C"
	// The cache base for rebasing must come from the cache itself (sharedCache
	// block or the source-"S" image), NOT a per-library source-"C" image base —
	// using img.Base for a "C" frame drops the library's offset within the cache.
	cacheBase, haveCacheBase := i.sharedCacheBase()

	switch {
	case isDSC && haveCacheBase && i.Config.DSCSlide != 0:
		addr = i.Config.DSCSlide + (addr - cacheBase)
		slideInfo = fmt.Sprintf(" (dsc-slide %#x)", i.Config.DSCSlide)
	case i.Config.Unslid && f.Slide != 0 && addr >= f.Slide:
		addr -= f.Slide
		slideInfo = fmt.Sprintf(" (unslid %#x)", f.Slide)
	}
	return addr, name, slideInfo
}

// sharedCacheBase returns the shared-cache base address for --dsc-slide
// rebasing, from the report's sharedCache block or, failing that, a source-"S"
// usedImage (the whole-cache image). Returns false when neither is present.
func (i *Ips) sharedCacheBase() (uint64, bool) {
	if i.Payload.SharedCache.Size > 0 {
		return i.Payload.SharedCache.Base, true
	}
	for idx := range i.Payload.UsedImages {
		if i.Payload.UsedImages[idx].Source == "S" {
			return i.Payload.UsedImages[idx].Base, true
		}
	}
	return 0, false
}

// fmtSymbolLocation renders a frame's symbol offset as " + N" (or " + 0xN" with
// --hex), or "" when there is no offset.
func (i *Ips) fmtSymbolLocation(loc uint64) string {
	if loc == 0 {
		return ""
	}
	if i.Config.Hex {
		return fmt.Sprintf(" + %#x", loc)
	}
	return fmt.Sprintf(" + %d", loc)
}

// openDSCs opens the dyld_shared_caches used for userspace symbolication. With
// an IPSW it mounts and opens the IPSW's caches; otherwise it opens the supplied
// paths (e.g. an Xcode DeviceSupport dump). The returned func releases the
// caches (and unmounts the IPSW when applicable).
func (i *Ips) openDSCs(ipswPath string, dscPaths []string) ([]*dyld.File, func(), error) {
	if ipswPath != "" {
		ctx, fs, err := dsc.OpenFromIPSW(ipswPath, i.Config.PemDB, false, true)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to open DSC from IPSW: %w", err)
		}
		return fs, func() {
			for _, f := range fs {
				f.Close()
			}
			ctx.Unmount()
		}, nil
	}

	var fs []*dyld.File
	for _, p := range dscPaths {
		f, err := dyld.Open(p)
		if err != nil {
			for _, opened := range fs {
				opened.Close()
			}
			return nil, nil, fmt.Errorf("failed to open DeviceSupport DSC %s: %w", p, err)
		}
		fs = append(fs, f)
	}
	return fs, func() {
		for _, f := range fs {
			f.Close()
		}
	}, nil
}

// hasSharedCacheImage reports whether any binary image is the shared cache,
// i.e. whether there are userspace frames a DSC/loose-dylib source could resolve.
func (i *Ips) hasSharedCacheImage() bool {
	for _, img := range i.Payload.BinaryImages {
		if img.Name == "dyld_shared_cache" {
			return true
		}
	}
	return false
}

// Symbolicate210 symbolicates a 210/288 panic crashlog. When ipswPath is set,
// it extracts the matching kernelcache and opens the IPSW's DSC for full
// kernel + userspace symbolication. When ipswPath is empty, only userspace
// frames are symbolicated (kernel frames are left raw) using, in order of
// preference: the dyld_shared_caches at dscPaths, or — when those are absent —
// the loose extracted dylibs under looseDir (a modern Xcode DeviceSupport
// "Symbols" dump, which no longer ships the cache itself).
func (i *Ips) Symbolicate210(ipswPath string, dscPaths []string, looseDir string) (err error) {

	i.Payload.panic210, err = parsePanicString210(i.Payload.PanicString)
	if err != nil {
		return fmt.Errorf("failed to parse panic string: %w", err)
	}

	total := len(i.Payload.BinaryImages)

	// add default binary image names
	for idx, img := range i.Payload.BinaryImages {
		switch img.Source {
		case "Absolute":
			i.Payload.BinaryImages[idx].Name = "absolute"
			total--
		case "Kernel":
			i.Payload.BinaryImages[idx].Name = "kernel"
			if i.Payload.panic210 != nil && i.Payload.panic210.KernelSlide != nil {
				i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelSlide.Value.(uint64)
			}
			total--
		case "KernelCache":
			i.Payload.BinaryImages[idx].Name = "kernelcache"
			if i.Payload.panic210 != nil {
				switch {
				case i.Payload.panic210.KernelCacheSlide != nil:
					i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelCacheSlide.Value.(uint64)
				case i.Payload.panic210.KernelSlide != nil:
					i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelSlide.Value.(uint64)
				}
			}
			total--
		case "KernelTextExec":
			i.Payload.BinaryImages[idx].Name = "kernelcache (__TEXT_EXEC)"
			if i.Payload.panic210 != nil && i.Payload.panic210.KernelTextExecSlide != nil {
				i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelTextExecSlide.Value.(uint64)
			}
			total--
		case "SharedCache":
			i.Payload.BinaryImages[idx].Name = "dyld_shared_cache"
			total--
		case "SharedCacheLibrary":
			i.Payload.BinaryImages[idx].Name = "dyld_shared_cache (library)"
			total--
		}
	}

	machoFuncMap := make(map[string][]types.Function)
	uuidFuncMap := make(map[string][]types.Function)

	// A bare kernelcache (Mach-O fileset) can be supplied instead of an IPSW;
	// detect it once so the IPSW-only filesystem/DSC paths are skipped below.
	bareKC := false
	if ipswPath != "" {
		bareKC, _ = magic.IsMachO(ipswPath)
	}

	/* SYMBOLICATE KERNELCACHE */
	var kc *macho.File
	if ipswPath != "" {
		var kcPaths []string
		if bareKC {
			// A bare kernelcache (Mach-O fileset) was supplied directly: use it
			// as-is for kernel-frame symbolication; never extract from an IPSW or
			// delete the user's file.
			log.WithField("kernelcache", filepath.Base(ipswPath)).Info("Using supplied kernelcache")
			kcPaths = []string{ipswPath}
		} else {
			// If crashlog has no device identifier, prompt user to select from available devices
			device := i.Payload.Product
			log.WithField("device", device).Debug("Looking for kernelcache matching crashlog device")
			if device == "" {
				ipswInfo, err := info.Parse(ipswPath)
				if err != nil {
					return fmt.Errorf("crashlog has no device identifier and failed to parse IPSW info: %w", err)
				}
				var devices []string
				for _, dtree := range ipswInfo.DeviceTrees {
					if dt, err := dtree.Summary(); err == nil {
						devices = append(devices, dt.ProductType)
					}
				}
				if len(devices) == 0 {
					return fmt.Errorf("crashlog has no device identifier and no devices found in IPSW")
				}
				sort.Strings(devices)
				if len(devices) == 1 {
					device = devices[0]
					log.Warnf("crashlog has no device identifier; using only available device: %s", device)
				} else {
					var choice string
					prompt := &survey.Select{
						Message:  "Crashlog has no device identifier. Select target device:",
						Options:  devices,
						PageSize: 15,
					}
					if err := survey.AskOne(prompt, &choice); err != nil {
						if err == terminal.InterruptErr {
							return fmt.Errorf("user cancelled device selection")
						}
						return fmt.Errorf("failed to select device: %w", err)
					}
					device = choice
				}
			}

			out, err := extract.Kernelcache(&extract.Config{
				IPSW:         ipswPath,
				KernelDevice: device,
				Output:       os.TempDir(),
			})
			if err != nil {
				return fmt.Errorf("failed to extract kernelcache: %w", err)
			}
			if len(out) == 0 {
				return fmt.Errorf("no kernelcache found for device %s in IPSW (multi-device IPSW may not contain this device)", device)
			}
			// Log which kernelcache(s) were found for the device
			for k := range out {
				log.WithFields(log.Fields{
					"device":      device,
					"kernelcache": filepath.Base(k),
				}).Debug("Found kernelcache for device")
			}
			defer func() {
				for k := range out {
					os.Remove(k)
				}
			}()
			for k := range out {
				kcPaths = append(kcPaths, k)
			}
		}

		// Collect all kernel-related UUIDs from crashlog for validation
		// kc.UUID() returns the LC_UUID of the kernelcache container
		type kernelUUIDInfo struct {
			name string
			uuid string
		}
		var crashlogKernelUUIDs []kernelUUIDInfo
		if i.Payload.panic210 != nil {
			if i.Payload.panic210.DebugHdrKernelCacheUUID != nil {
				crashlogKernelUUIDs = append(crashlogKernelUUIDs, kernelUUIDInfo{
					name: "Debug Header KernelCache UUID",
					uuid: i.Payload.panic210.DebugHdrKernelCacheUUID.Value.(string),
				})
			}
			if i.Payload.panic210.FilesetKernelCacheUUID != nil {
				crashlogKernelUUIDs = append(crashlogKernelUUIDs, kernelUUIDInfo{
					name: "Fileset KernelCache UUID",
					uuid: i.Payload.panic210.FilesetKernelCacheUUID.Value.(string),
				})
			}
			if i.Payload.panic210.KernelCacheUUID != nil {
				crashlogKernelUUIDs = append(crashlogKernelUUIDs, kernelUUIDInfo{
					name: "KernelCache UUID",
					uuid: i.Payload.panic210.KernelCacheUUID.Value.(string),
				})
			}
			if i.Payload.panic210.KernelUUID != nil {
				crashlogKernelUUIDs = append(crashlogKernelUUIDs, kernelUUIDInfo{
					name: "Kernel UUID",
					uuid: i.Payload.panic210.KernelUUID.Value.(string),
				})
			}
		}

		var sigs []signature.Symbolicator
		if i.Config.SignaturesDir != "" {
			sigs, err = signature.Parse(i.Config.SignaturesDir)
			if err != nil {
				return fmt.Errorf("failed to parse signatures: %v", err)
			}
		}

		for _, k := range kcPaths {
			smap := signature.NewSymbolMap()
			log.WithField("kernelcache", filepath.Base(k)).Info("Symbolicating...")
			if err := smap.Symbolicate(k, sigs, !i.Config.Verbose); err != nil {
				return fmt.Errorf("failed to symbolicate kernelcache: %v", err)
			}
			// Store symbols for IDA script generation.
			i.KernelSymbols = smap

			kc, err = macho.Open(k)
			if err != nil {
				return fmt.Errorf("failed to open kernelcache: %v", err)
			}
			defer kc.Close()

			// Validate kernelcache UUID matches any of the crashlog's kernel UUIDs
			if len(crashlogKernelUUIDs) > 0 {
				if kcUUID := kc.UUID(); kcUUID != nil {
					// Log the IPSW kernelcache UUID we extracted
					log.WithField("uuid", kcUUID.UUID.String()).Info("IPSW kernelcache UUID")

					// Log all crashlog kernel UUIDs for comparison
					for _, info := range crashlogKernelUUIDs {
						log.WithFields(log.Fields{
							"field": info.name,
							"uuid":  info.uuid,
						}).Debug("Crashlog kernel UUID")
					}

					// Normalize IPSW UUID: uppercase and no dashes for comparison
					actualUUID := strings.ToUpper(strings.ReplaceAll(kcUUID.UUID.String(), "-", ""))

					// Check against all kernel-related UUIDs from crashlog
					matched := false
					var matchedField string
					for _, info := range crashlogKernelUUIDs {
						expectedUUID := strings.ToUpper(strings.ReplaceAll(info.uuid, "-", ""))
						if actualUUID == expectedUUID {
							matched = true
							matchedField = info.name
							break
						}
					}

					if matched {
						log.WithFields(log.Fields{
							"field": matchedField,
							"uuid":  kcUUID.UUID.String(),
						}).Info("Kernelcache UUID validated")
					} else {
						// Log all the UUIDs we tried to match against
						var uuidList []string
						for _, info := range crashlogKernelUUIDs {
							uuidList = append(uuidList, fmt.Sprintf("%s=%s", info.name, info.uuid))
						}
						log.WithFields(log.Fields{
							"ipsw_uuid":      kcUUID.UUID.String(),
							"crashlog_uuids": strings.Join(uuidList, ", "),
						}).Warn("Kernelcache UUID mismatch: IPSW UUID doesn't match any crashlog kernel UUID")
					}
				}
			} else {
				log.Debug("No kernelcache UUID in crashlog, skipping validation")
			}

			if kc.FileTOC.FileHeader.Type == types.MH_FILESET {
				for _, fe := range kc.FileSets() {
					mfe, err := kc.GetFileSetFileByName(fe.EntryID)
					if err != nil {
						return fmt.Errorf("failed to parse entry %s: %v", fe.EntryID, err)
					}
					for _, fn := range mfe.GetFunctions() {
						if syms, err := mfe.FindAddressSymbols(fn.StartAddr); err == nil {
							for _, sym := range syms {
								fn.Name = sym.Name
							}
						} else {
							if sym, ok := smap[fn.StartAddr]; ok {
								fn.Name = sym
							}
						}
						if fn.Name == "" {
							fn.Name = fmt.Sprintf("func_%x", fn.StartAddr)
						}
						machoFuncMap["kernelcache"] = append(machoFuncMap["kernelcache"], fn)
						// Also map under "kernel" for Source=Kernel frames
						machoFuncMap["kernel"] = append(machoFuncMap["kernel"], fn)
						// Also map under "kernelcache (__TEXT_EXEC)" for Source=KernelTextExec frames
						machoFuncMap["kernelcache (__TEXT_EXEC)"] = append(machoFuncMap["kernelcache (__TEXT_EXEC)"], fn)
					}
				}
			} else { // non-fileset kernelcache
				for _, fn := range kc.GetFunctions() {
					if syms, err := kc.FindAddressSymbols(fn.StartAddr); err == nil {
						for _, sym := range syms {
							fn.Name = sym.Name
						}
					} else {
						if sym, ok := smap[fn.StartAddr]; ok {
							fn.Name = sym
						}
						if fn.Name == "" {
							fn.Name = fmt.Sprintf("func_%x", fn.StartAddr)
						}
					}
					machoFuncMap["kernelcache"] = append(machoFuncMap["kernelcache"], fn)
					// Also map under "kernel" for Source=Kernel frames
					machoFuncMap["kernel"] = append(machoFuncMap["kernel"], fn)
					// Also map under "kernelcache (__TEXT_EXEC)" for Source=KernelTextExec frames
					machoFuncMap["kernelcache (__TEXT_EXEC)"] = append(machoFuncMap["kernelcache (__TEXT_EXEC)"], fn)
				}
			}
		}
	}

	/* SYMBOLICATE EXTRA MACHOS */
	if i.Config.ExtrasDir != "" {
		if err := search.ForEachMacho(i.Config.ExtrasDir, func(path string, m *macho.File) error {
			for idx, img := range i.Payload.BinaryImages {
				if m.UUID() != nil && strings.EqualFold(img.UUID, m.UUID().UUID.String()) {
					i.Payload.BinaryImages[idx].Path = path
					i.Payload.BinaryImages[idx].Name = path
					// i.Payload.BinaryImages[idx].Name = filepath.Base(path)
					i.Payload.BinaryImages[idx].Slide = i.Payload.BinaryImages[idx].Base - m.GetBaseAddress()
					for _, fn := range m.GetFunctions() {
						if syms, err := m.FindAddressSymbols(fn.StartAddr); err == nil {
							for _, sym := range syms {
								fn.Name = sym.Name
							}
							fn.StartAddr += i.Payload.BinaryImages[idx].Slide
							fn.EndAddr += i.Payload.BinaryImages[idx].Slide
							machoFuncMap[i.Payload.BinaryImages[idx].Name] = append(machoFuncMap[i.Payload.BinaryImages[idx].Name], fn)
							uuidFuncMap[strings.ToUpper(img.UUID)] = append(uuidFuncMap[strings.ToUpper(img.UUID)], fn)
						} else {
							fn.StartAddr += i.Payload.BinaryImages[idx].Slide
							fn.EndAddr += i.Payload.BinaryImages[idx].Slide
							fn.Name = fmt.Sprintf("func_%x", fn.StartAddr)
							machoFuncMap[i.Payload.BinaryImages[idx].Name] = append(machoFuncMap[i.Payload.BinaryImages[idx].Name], fn)
							uuidFuncMap[strings.ToUpper(img.UUID)] = append(uuidFuncMap[strings.ToUpper(img.UUID)], fn)
						}
					}
					total--
				}
			}
			return nil
		}); err != nil {
			if !errors.Is(err, ErrDone) {
				return fmt.Errorf("failed to symbolicate: %w", err)
			}
		}
	}

	/* SYMBOLICATE FILESYSTEM MACHOS */
	if ipswPath != "" && !bareKC {
		if err := search.ForEachMachoInIPSW(ipswPath, i.Config.PemDB, func(path string, m *macho.File) error {
			if total == 0 {
				return ErrDone // break
			}
			for idx, img := range i.Payload.BinaryImages {
				if m.UUID() != nil && strings.EqualFold(img.UUID, m.UUID().UUID.String()) {
					i.Payload.BinaryImages[idx].Path = path
					i.Payload.BinaryImages[idx].Name = path
					// i.Payload.BinaryImages[idx].Name = filepath.Base(path)
					i.Payload.BinaryImages[idx].Slide = i.Payload.BinaryImages[idx].Base - m.GetBaseAddress()
					// Read peek bytes for panicked thread user frames from this binary (--peek flag)
					if i.Config.Peek && i.Payload.panic210 != nil {
						panicPID := i.Payload.panic210.PanickedTask.PID
						panicTID := i.Payload.panic210.PanickedThread.TID
						if proc, ok := i.Payload.ProcessByPid[panicPID]; ok {
							if thread, ok := proc.ThreadByID[panicTID]; ok {
								for frameIdx := range thread.UserFrames {
									frame := &i.Payload.ProcessByPid[panicPID].ThreadByID[panicTID].UserFrames[frameIdx]
									if frame.ImageIndex == uint64(idx) {
										// Compute the VM address relative to MachO base (not runtime base)
										// frame.ImageOffset is raw offset within image
										// m.GetBaseAddress() is MachO's preferred load address
										vmAddr := m.GetBaseAddress() + frame.ImageOffset
										// Get function boundaries for boundary checking
										var funcStart, funcEnd uint64
										if fn, err := m.GetFunctionForVMAddr(vmAddr); err == nil {
											funcStart = fn.StartAddr
											funcEnd = fn.EndAddr
										}
										if peekBytes, startAddr, frameIdx := readPeekBytes(m, vmAddr, i.Config.PeekCount, funcStart, funcEnd); peekBytes != nil {
											frame.PeekBytes = peekBytes
											// Store the runtime (slid) address for display
											slide := i.Payload.BinaryImages[idx].Slide
											slidAddr := startAddr + slide
											frame.PeekAddr = slidAddr
											frame.PeekFrameIdx = frameIdx
											// Extract symbols for branch targets in peek bytes
											// Use slid address so keys match what formatPeekDisassembly will look up
											frame.PeekSymbols = extractPeekSymbols(peekBytes, slidAddr, slide, m)
										}
									}
								}
							}
						}
					}
					for _, fn := range m.GetFunctions() {
						if syms, err := m.FindAddressSymbols(fn.StartAddr); err == nil {
							for _, sym := range syms {
								fn.Name = sym.Name
							}
							fn.StartAddr += i.Payload.BinaryImages[idx].Slide
							fn.EndAddr += i.Payload.BinaryImages[idx].Slide
							machoFuncMap[i.Payload.BinaryImages[idx].Name] = append(machoFuncMap[i.Payload.BinaryImages[idx].Name], fn)
							uuidFuncMap[strings.ToUpper(img.UUID)] = append(uuidFuncMap[strings.ToUpper(img.UUID)], fn)
						} else {
							fn.StartAddr += i.Payload.BinaryImages[idx].Slide
							fn.EndAddr += i.Payload.BinaryImages[idx].Slide
							fn.Name = fmt.Sprintf("func_%x", fn.StartAddr)
							machoFuncMap[i.Payload.BinaryImages[idx].Name] = append(machoFuncMap[i.Payload.BinaryImages[idx].Name], fn)
							uuidFuncMap[strings.ToUpper(img.UUID)] = append(uuidFuncMap[strings.ToUpper(img.UUID)], fn)
						}
					}
					total--
				}
			}
			return nil
		}); err != nil {
			if !errors.Is(err, ErrDone) {
				return fmt.Errorf("failed to symbolicate: %w", err)
			}
		}
	}

	// print any binary images that are missing names
	// for idx, img := range i.Payload.BinaryImages {
	// 	if len(img.Name) == 0 {
	// 		fmt.Printf("idx: %d - %v\n", idx, img)
	// 	}
	// }

	dscIPSW := ipswPath
	if bareKC {
		dscIPSW = "" // a bare kernelcache has no DSC; userspace frames stay raw
	}
	fs, closeDSCs, err := i.openDSCs(dscIPSW, dscPaths)
	if err != nil {
		return err
	}
	defer closeDSCs()

	// When no dyld_shared_cache is available, fall back to the loose extracted
	// dylibs from an Xcode DeviceSupport dump for userspace symbolication. Only
	// build the (expensive) index if the crash actually has shared-cache frames.
	var lc *looseCache
	if len(fs) == 0 && looseDir != "" && i.hasSharedCacheImage() {
		if lc, err = newLooseCache(looseDir); err != nil {
			log.WithError(err).Warn("failed to index DeviceSupport dylibs")
			lc = nil
		} else {
			defer lc.Close()
		}
	}

	for pid, proc := range i.Payload.ProcessByPid {
		for tid, thread := range proc.ThreadByID {
			for idx, frame := range thread.UserFrames {
				if int(frame.ImageIndex) >= len(i.Payload.BinaryImages) {
					continue // frame references an image outside the table (corrupt/truncated)
				}
				i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset += i.Payload.BinaryImages[frame.ImageIndex].Base
				if i.Payload.BinaryImages[frame.ImageIndex].Slide != 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Slide
				}
				if len(i.Payload.BinaryImages[frame.ImageIndex].Name) > 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName = i.Payload.BinaryImages[frame.ImageIndex].Name
				} else {
					if strings.HasPrefix(i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName, "image_") {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName += fmt.Sprintf(" (probably %s)", proc.Name)
					}
				}
				if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "absolute" {
					continue // skip absolute
				} else if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "dyld_shared_cache" {
					for _, f := range fs {
						if !strings.EqualFold(i.Payload.BinaryImages[frame.ImageIndex].UUID, f.UUID.String()) {
							continue // skip to next DSC
						}
						if f.Headers[f.UUID].SharedRegionStart != i.Payload.BinaryImages[frame.ImageIndex].Base {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Base - f.Headers[f.UUID].SharedRegionStart
						}
						// lookup symbol in DSC dylib
						if img, err := f.GetImageContainingVMAddr(i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset); err == nil {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName = filepath.Base(img.Name)
							img.ParsePublicSymbols(false)
							img.ParseLocalSymbols(false)
							m, err := img.GetMacho()
							if err != nil {
								return fmt.Errorf("failed to get macho from image: %w", err)
							}
							if fn, err := m.GetFunctionForVMAddr(i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset); err == nil {
								if sym, ok := f.AddressToSymbol.Get(fn.StartAddr); ok {
									i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = demangleSym(i.Config.Demangle, sym)
									if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-fn.StartAddr != 0 {
										i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].SymbolLocation = i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset - fn.StartAddr
									}
								} else {
									i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = fmt.Sprintf("func_%x", fn.StartAddr)
								}
							}
						}
					}
					if lc != nil {
						uf := &i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx]
						if name, sym, symStart, ok := lc.symbolicate(uf.ImageOffset); ok {
							if name != "" {
								uf.ImageName = name
							}
							if sym != "" {
								uf.Symbol = demangleSym(i.Config.Demangle, sym)
								if uf.ImageOffset-symStart != 0 {
									uf.SymbolLocation = uf.ImageOffset - symStart
								}
							}
						}
					}
				} else { // "Process"
					// lookup symbol in MachO symbol map (by name, then fall back to UUID)
					funcs, ok := machoFuncMap[i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName]
					if !ok {
						uuid := strings.ToUpper(i.Payload.BinaryImages[frame.ImageIndex].UUID)
						funcs, ok = uuidFuncMap[uuid]
					}
					if ok {
						found := false
						for _, fn := range funcs {
							if fn.StartAddr <= i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset && i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset < fn.EndAddr {
								found = true
								i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = demangleSym(i.Config.Demangle, fn.Name)
								if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-fn.StartAddr != 0 {
									i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].SymbolLocation = i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset - fn.StartAddr
								}
								break
							}
						}
						if !found {
							// sometimes a frame will report using `dyld` but the offset is not in any function or in the binary image (e.g. in memory macho)
							if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "/usr/lib/dyld" {
								i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName += " (??? in memory macho)"
							}
							log.WithFields(log.Fields{
								"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
								"thread": tid,
								"frame":  idx,
								"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName,
							}).Debugf("failed to find function for process offset %#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset)
						}
					} else {
						log.WithFields(log.Fields{
							"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
							"thread": tid,
							"frame":  idx,
							"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName,
						}).Debugf("failed to find function for process offset %#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset)
					}
				}
			}
			/* KernelFrames */
			if kc != nil {
				for idx, frame := range thread.KernelFrames {
					if int(frame.ImageIndex) >= len(i.Payload.BinaryImages) {
						continue // frame references an image outside the table (corrupt/truncated)
					}
					i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset += i.Payload.BinaryImages[frame.ImageIndex].Base
					if i.Payload.BinaryImages[frame.ImageIndex].Slide != 0 {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Slide
					}
					if len(i.Payload.BinaryImages[frame.ImageIndex].Name) > 0 {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName = i.Payload.BinaryImages[frame.ImageIndex].Name
					} else {
						if strings.HasPrefix(i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName, "image_") {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName += " (maybe a kext)"
						}
					}
					if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName == "absolute" {
						continue // skip absolute
					} else if funcs, ok := machoFuncMap[i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName]; ok {
						found := false
						for _, fn := range funcs {
							if fn.StartAddr <= i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset &&
								i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset < fn.EndAddr {
								found = true
								i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Symbol = demangleSym(i.Config.Demangle, fn.Name)
								if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset-fn.StartAddr != 0 {
									i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].SymbolLocation = i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset - fn.StartAddr
								}
								break
							}
						}
						if !found {
							// if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName == "??" {
							// 	i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName += " (??? maybe kext?)"
							// }
							if seg := kc.FindSegmentForVMAddr(i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset); seg != nil {
								i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Symbol = seg.Name
								if sec := kc.FindSectionForVMAddr(i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset); sec != nil {
									i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Symbol += "." + sec.Name
								}
							} else {
								i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Symbol = "???"
								log.WithFields(log.Fields{
									"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
									"thread": tid,
									"frame":  idx,
									"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName,
								}).Debugf("failed to find function for process offset %#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset)
							}
						}
					} else {
						log.WithFields(log.Fields{
							"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
							"thread": tid,
							"frame":  idx,
							"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName,
							"offset": fmt.Sprintf("%#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset),
						}).Errorf("unexpected image")
					}
				}
			}

			// Read peek bytes for panicked thread frames (--peek flag)
			if i.Config.Peek && i.Payload.panic210 != nil &&
				pid == i.Payload.panic210.PanickedTask.PID &&
				tid == i.Payload.panic210.PanickedThread.TID {
				// cache already-opened machos so we don't reopen for each frame
				machoCache := make(map[int]*macho.File)
				defer func() {
					for _, m := range machoCache {
						m.Close()
					}
				}()

				// Read peek bytes for kernel frames from kernelcache
				if kc != nil {
					for idx := range i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames {
						frame := &i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx]
						if frame.ImageName == "absolute" {
							continue
						}
						if frame.ImageOffset > 0 {
							// Get function boundaries for boundary checking
							var funcStart, funcEnd uint64
							if fn, err := kc.GetFunctionForVMAddr(frame.ImageOffset); err == nil {
								funcStart = fn.StartAddr
								funcEnd = fn.EndAddr
							}
							// Use the runtime (slid) address for reading; kc segments are already in slid VM space.
							if peekBytes, startAddr, frameIdx := readPeekBytes(kc, frame.ImageOffset, i.Config.PeekCount, funcStart, funcEnd); peekBytes != nil {
								frame.PeekBytes = peekBytes
								frame.PeekAddr = startAddr
								frame.PeekFrameIdx = frameIdx
								// Extract symbols for branch targets in peek bytes (kernel has no slide)
								frame.PeekSymbols = extractPeekSymbols(peekBytes, startAddr, 0, kc)
							}
						}
					}
				}
				// Read peek bytes for user frames from DSC (process binaries already handled during ForEachMachoInIPSW)
				for idx := range i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames {
					frame := &i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx]
					if frame.ImageName == "absolute" || len(frame.PeekBytes) > 0 {
						continue // skip absolute or frames already populated from filesystem
					}
					if frame.ImageOffset > 0 {
						frameSlide := frame.Slide
						if frameSlide == 0 && int(frame.ImageIndex) < len(i.Payload.BinaryImages) {
							frameSlide = i.Payload.BinaryImages[frame.ImageIndex].Slide
						}
						unslidAddr := frame.ImageOffset
						if frameSlide != 0 && unslidAddr >= frameSlide {
							unslidAddr -= frameSlide
						}
						// 1) Try using a located MachO on disk (extras/IPSW/symbol server)
						if bi := i.Payload.BinaryImages[frame.ImageIndex]; len(bi.Path) > 0 {
							if m, ok := machoCache[int(frame.ImageIndex)]; ok {
								var funcStart, funcEnd uint64
								if fn, err := m.GetFunctionForVMAddr(unslidAddr); err == nil {
									funcStart = fn.StartAddr
									funcEnd = fn.EndAddr
								}
								if peekBytes, startAddr, peekFrameIdx := readPeekBytes(m, unslidAddr, i.Config.PeekCount, funcStart, funcEnd); peekBytes != nil {
									frame.PeekBytes = peekBytes
									slidAddr := startAddr + frameSlide
									frame.PeekAddr = slidAddr
									frame.PeekFrameIdx = peekFrameIdx
									// Extract symbols for branch targets in peek bytes
									frame.PeekSymbols = extractPeekSymbols(peekBytes, slidAddr, frameSlide, m)
									continue
								}
							} else if m, err := macho.Open(bi.Path); err == nil {
								machoCache[int(frame.ImageIndex)] = m
								var funcStart, funcEnd uint64
								if fn, err := m.GetFunctionForVMAddr(unslidAddr); err == nil {
									funcStart = fn.StartAddr
									funcEnd = fn.EndAddr
								}
								if peekBytes, startAddr, peekFrameIdx := readPeekBytes(m, unslidAddr, i.Config.PeekCount, funcStart, funcEnd); peekBytes != nil {
									frame.PeekBytes = peekBytes
									slidAddr := startAddr + frameSlide
									frame.PeekAddr = slidAddr
									frame.PeekFrameIdx = peekFrameIdx
									// Extract symbols for branch targets in peek bytes
									frame.PeekSymbols = extractPeekSymbols(peekBytes, slidAddr, frameSlide, m)
									continue
								}
							} else {
								log.WithError(err).Debugf("failed to open macho for peek: %s", bi.Path)
							}
						}

						// 2) Try reading from DSC as a fallback
						for _, f := range fs {
							if img, err := f.GetImageContainingVMAddr(frame.ImageOffset); err == nil {
								if m, err := img.GetMacho(); err == nil {
									unslidAddr := frame.ImageOffset
									if frameSlide != 0 && unslidAddr >= frameSlide {
										unslidAddr -= frameSlide
									}
									var funcStart, funcEnd uint64
									if fn, err := m.GetFunctionForVMAddr(unslidAddr); err == nil {
										funcStart = fn.StartAddr
										funcEnd = fn.EndAddr
									}
									if peekBytes, startAddr, peekFrameIdx := readPeekBytes(m, unslidAddr, i.Config.PeekCount, funcStart, funcEnd); peekBytes != nil {
										frame.PeekBytes = peekBytes
										slidAddr := startAddr + frameSlide
										frame.PeekAddr = slidAddr
										frame.PeekFrameIdx = peekFrameIdx
										// Extract symbols for branch targets in peek bytes
										frame.PeekSymbols = extractPeekSymbols(peekBytes, slidAddr, frameSlide, m)
									}
								}
								break
							}
						}
					}
				}
			}
		}
	}

	// Generate IDA Python scripts if requested
	if i.Config.IDAScript {
		if err := i.generateIDAScripts(); err != nil {
			return fmt.Errorf("failed to generate IDA scripts: %w", err)
		}
	}

	return nil
}

func (i *Ips) Symbolicate210WithDatabase(dbURL string) (err error) {

	db := server.NewServer(dbURL)

	if err := db.Ping(); err != nil {
		return fmt.Errorf("failed symbolicate panic 210: %w", err)
	}

	if ok, err := db.HasIPSW(i.Header.Version(), i.Header.Build(), i.Payload.Product); err != nil {
		return fmt.Errorf("failed symbolicate panic 210: %w", err)
	} else {
		if !ok {
			need := fmt.Sprintf("%s (%s) for %s", i.Header.Version(), i.Header.Build(), i.Payload.Product)
			return fmt.Errorf("failed symbolicate panic 210: required IPSW not found in symbol server database; need %s", need)
		}
	}

	i.Payload.panic210, err = parsePanicString210(i.Payload.PanicString)
	if err != nil {
		return fmt.Errorf("failed to parse panic string: %w", err)
	}

	total := len(i.Payload.BinaryImages)

	// add default binary image names
	for idx, img := range i.Payload.BinaryImages {
		switch img.Source {
		case "Absolute":
			i.Payload.BinaryImages[idx].Name = "absolute"
			total--
		case "Kernel":
			i.Payload.BinaryImages[idx].Name = "kernel"
			if i.Payload.panic210 != nil && i.Payload.panic210.KernelSlide != nil {
				i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelSlide.Value.(uint64)
			}
			total--
		case "KernelCache":
			i.Payload.BinaryImages[idx].Name = "kernelcache"
			if i.Payload.panic210 != nil {
				// if i.Payload.panic210.KernelTextBase != nil {
				// 	i.Payload.BinaryImages[idx].Base = i.Payload.panic210.KernelTextBase.Value.(uint64)
				// }
				if i.Payload.panic210.KernelCacheSlide != nil {
					i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelCacheSlide.Value.(uint64)
				} else if i.Payload.panic210.KernelSlide != nil {
					i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelSlide.Value.(uint64)
				}
				if i.Payload.panic210.KernelUUID != nil {
					i.Payload.BinaryImages[idx].UUID = i.Payload.panic210.KernelUUID.Value.(string)
				}
			}
			total--
		case "KernelTextExec":
			i.Payload.BinaryImages[idx].Name = "kernelcache (__TEXT_EXEC)"
			if i.Payload.panic210 != nil && i.Payload.panic210.KernelTextExecSlide != nil {
				i.Payload.BinaryImages[idx].Slide = i.Payload.panic210.KernelTextExecSlide.Value.(uint64)
			}
			total--
		case "SharedCache":
			i.Payload.BinaryImages[idx].Name = "dyld_shared_cache"
			total--
		case "SharedCacheLibrary":
			i.Payload.BinaryImages[idx].Name = "dyld_shared_cache (library)"
			total--
		}
	}

	for idx, img := range i.Payload.BinaryImages {
		if m, err := db.GetMachO(strings.ToUpper(img.UUID)); err == nil {
			if i.Payload.BinaryImages[idx].Name != "kernelcache" {
				i.Payload.BinaryImages[idx].Path = m.GetPath()
				i.Payload.BinaryImages[idx].Name = m.GetPath()
				i.Payload.BinaryImages[idx].Slide = i.Payload.BinaryImages[idx].Base - m.TextStart
			}
			total--
		} else {
			log.WithFields(log.Fields{
				"uuid": img.UUID,
				"name": img.Name,
			}).Debug("failed to find macho for uuid")
		}
	}

	if total > 0 {
		log.WithFields(log.Fields{
			"total": total,
		}).Debug("missing binary images")
	}

	for pid, proc := range i.Payload.ProcessByPid {
		for tid, thread := range proc.ThreadByID {
			for idx, frame := range thread.UserFrames {
				if int(frame.ImageIndex) >= len(i.Payload.BinaryImages) {
					continue // frame references an image outside the table (corrupt/truncated)
				}
				i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset += i.Payload.BinaryImages[frame.ImageIndex].Base
				if i.Payload.BinaryImages[frame.ImageIndex].Slide != 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Slide
				}
				if len(i.Payload.BinaryImages[frame.ImageIndex].Name) > 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName = i.Payload.BinaryImages[frame.ImageIndex].Name
				} else {
					if strings.HasPrefix(i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName, "image_") {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName += fmt.Sprintf(" (probably %s)", proc.Name)
					}
				}
				if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "absolute" {
					continue // skip absolute
				} else if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "dyld_shared_cache" {
					dsc, err := db.GetDSC(strings.ToUpper(i.Payload.BinaryImages[frame.ImageIndex].UUID))
					if err != nil {
						return fmt.Errorf("failed to get DSC for uuid: %w", err)
					}
					if dsc.SharedRegionStart != i.Payload.BinaryImages[frame.ImageIndex].Base {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Base - dsc.SharedRegionStart
					}
					// lookup symbol in DSC dylib
					if img, err := db.GetDSCImage(dsc.UUID, i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset); err == nil {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName = filepath.Base(img.GetPath())
						if sym, err := db.GetSymbol(img.UUID, i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset); err == nil {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = demangleSym(i.Config.Demangle, sym.GetName())
							if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-sym.Start != 0 {
								i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].SymbolLocation = i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset - sym.Start
							}
						} else {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = fmt.Sprintf("func_%x", i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset)
						}
					} else {
						log.WithFields(log.Fields{
							"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
							"thread": tid,
							"frame":  idx,
							"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName,
						}).Debugf("failed to find DSC image containing offset %#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset)
					}
				} else { // "Process"
					if sym, err := db.GetSymbol(
						strings.ToUpper(i.Payload.BinaryImages[frame.ImageIndex].UUID),
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide,
					); err == nil {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Symbol = demangleSym(i.Config.Demangle, sym.GetName())
						if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-sym.Start != 0 {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].SymbolLocation =
								i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset -
									i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide -
									sym.Start
						}
					} else {
						// sometimes a frame will report using `dyld` but the offset is not in any function or in the binary image (e.g. in memory macho)
						if i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName == "/usr/lib/dyld" {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName += " (??? in memory macho)"
						}
						log.WithFields(log.Fields{
							"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
							"thread": tid,
							"frame":  idx,
							"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageName,
						}).Debugf("failed to find symbol for process offset %#x",
							i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].ImageOffset-
								i.Payload.ProcessByPid[pid].ThreadByID[tid].UserFrames[idx].Slide)
					}
				}
			}
			for idx, frame := range thread.KernelFrames {
				i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset += i.Payload.BinaryImages[frame.ImageIndex].Base
				if i.Payload.BinaryImages[frame.ImageIndex].Slide != 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Slide = i.Payload.BinaryImages[frame.ImageIndex].Slide
				}
				if len(i.Payload.BinaryImages[frame.ImageIndex].Name) > 0 {
					i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName = i.Payload.BinaryImages[frame.ImageIndex].Name
				} else {
					if strings.HasPrefix(i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName, "image_") {
						i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName += " (maybe a kext)"
					}
				}
				if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName == "absolute" {
					continue // skip absolute
				} else if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName == "kernelcache" {
					if sym, err := db.GetSymbol(
						strings.ToUpper(i.Payload.BinaryImages[frame.ImageIndex].UUID),
						i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset & ^uint64(1<<63),
					); err == nil {
						sym.Start |= uint64(1 << 63)
						sym.End |= uint64(1 << 63)
						i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].Symbol = demangleSym(i.Config.Demangle, sym.GetName())
						if i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset-sym.Start != 0 {
							i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].SymbolLocation =
								i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset - sym.Start
						}
					} else {
						log.WithFields(log.Fields{
							"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
							"thread": tid,
							"frame":  idx,
							"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName,
						}).Debugf("failed to find symbol for kernel frame image offset %#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset)
					}
				} else {
					log.WithFields(log.Fields{
						"proc":   fmt.Sprintf("%s [%d]", proc.Name, pid),
						"thread": tid,
						"frame":  idx,
						"img":    i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageName,
						"offset": fmt.Sprintf("%#x", i.Payload.ProcessByPid[pid].ThreadByID[tid].KernelFrames[idx].ImageOffset),
					}).Errorf("unexpected image")
				}
			}
		}
	}

	// Generate IDA scripts if requested
	if i.Config.IDAScript {
		if err := i.generateIDAScripts(); err != nil {
			return fmt.Errorf("failed to generate IDA scripts: %w", err)
		}
	}

	return nil
}

func fmtState(states []string) string {
	var out []string
	for _, s := range states {
		switch s {
		case "TH_WAIT", "TH_UNINT":
			out = append(out, colorAddr(s))
		case "TH_RUN", "TH_IDLE":
			out = append(out, colorBold(s))
		default:
			out = append(out, s)
		}
	}
	return strings.Join(out, ", ")
}

func colorVMSummary(in string) string {
	separatorMatch := regexp.MustCompile(`=+`)
	in = separatorMatch.ReplaceAllStringFunc(in, func(s string) string {
		return colorField(s)
	})
	return in
}

func (i *Ips) String() string {
	if render, ok := ipsRenderers[i.Header.BugType]; ok {
		return render(i)
	}
	return fmt.Sprintf("%s: (unsupported, notify author)", i.Header.BugType)
}

// headerLine renders the "[time] - BugType - Product Build" banner shared by the
// panic (210/288) and watchdog (409) renderers. Watchdog stackshot payloads carry
// the OS string in osVersion/os_version rather than a top-level build, so fall
// back to the header's os_version when Payload.Build is empty.
func (i *Ips) headerLine() string {
	osVer := i.Payload.Build
	if osVer == "" {
		osVer = i.Header.OsVersion
	}
	return fmt.Sprintf("[%s] - %s - %s %s\n\n", colorTime(i.Header.Timestamp.Format("02Jan2006 15:04:05")), colorError(i.Header.BugTypeDesc), i.Payload.Product, osVer)
}

// panicString renders kernel panic / stackshot reports (bug_type 210 / 288).
func (i *Ips) panicString() string {
	var out strings.Builder
	out.WriteString(i.headerLine())
	if i.Payload.panic210 == nil {
		var err error
		i.Payload.panic210, err = parsePanicString210(i.Payload.PanicString)
		if err != nil {
			log.Errorf("failed to parse panic string: %w", err)
		}
	}
	if i.Payload.panic210 == nil { // never deref a nil panic210 below (empty/garbage panic string)
		i.Payload.panic210 = &Panic210{}
	}
	if i.Config.Verbose {
		if len(i.Payload.PanicString) > 0 {
			out.WriteString(fmt.Sprintf("%s: %s\n", colorField("Panic String"), i.Payload.PanicString))
		}
	} else {
		out.WriteString(i.Payload.panic210.String())
	}
	if len(i.Payload.PanicFlags) > 0 {
		out.WriteString(fmt.Sprintf("%s: %s\n", colorField("Panic Flags"), i.Payload.PanicFlags))
	}
	out.WriteString("\n" + i.Payload.MemoryStatus.String())
	out.WriteString(i.Payload.OtherString + "\n")
	out.WriteString(i.processDumpString(i.Payload.panic210.PanickedTask.PID, i.Payload.panic210.PanickedThread.TID))
	if len(i.Payload.Notes) > 0 {
		out.WriteString(colorField("NOTES") + ":\n")
		for _, n := range i.Payload.Notes {
			out.WriteString(fmt.Sprintf("    - %s\n", n))
		}
	}
	return out.String()
}

// processDumpString renders the stackshot process/thread table shared by kernel
// panics (210/288) and SystemWatchdogCrash (409). panickedPID/panickedTID mark
// the offending process/thread (pass -1 to mark none). With Config.All unset the
// dump is filtered to the marked process plus any matching Running/Process
// selection, so callers always surface the process of interest.
func (i *Ips) processDumpString(panickedPID, panickedTID int) string {
	var out strings.Builder
	var pids []int
	for pid := range i.Payload.ProcessByPid {
		pids = append(pids, pid)
	}
	sort.Ints(pids)
	for _, pid := range pids {
		p := i.Payload.ProcessByPid[pid]
		paniced := ""
		if p.ID == panickedPID {
			paniced = colorError(" (Panicked)")
		} else {
			/* filter procs */
			if !i.Config.All {
				if i.Config.Running {
					notRunning := true
					// check if any thread is running
					for _, t := range p.ThreadByID {
						if slices.Contains(t.State, "TH_RUN") {
							notRunning = false
							break
						}
					}
					if notRunning {
						continue
					}
				} else if len(i.Config.Process) > 0 {
					if p.Name != i.Config.Process {
						continue
					}
				} else {
					continue
				}
			}
		}
		out.WriteString(fmt.Sprintf(colorField("Process")+": %s [%s]%s\n", colorImage(p.Name), colorBold("%d", p.ID), paniced))
		// When the marked process has no single flagged thread (panickedTID < 0,
		// e.g. a watchdog timeout), show all of its threads — it is the subject.
		showAllThreads := p.ID == panickedPID && panickedTID < 0
		tids := make([]int, 0, len(p.ThreadByID))
		for tid := range p.ThreadByID {
			tids = append(tids, tid)
		}
		sort.Ints(tids)
		for _, tid := range tids {
			t := p.ThreadByID[tid]
			paniced = ""
			if t.ID == panickedTID {
				paniced = colorError("       (Panicked)")
			} else if !showAllThreads {
				/* filter threads */
				if !i.Config.All {
					if i.Config.Running {
						if !slices.Contains(t.State, "TH_RUN") {
							continue
						}
					} else if len(i.Config.Process) > 0 {
						if p.Name != i.Config.Process {
							continue
						}
					} else {
						continue
					}
				}
			}
			out.WriteString(fmt.Sprintf(colorField("  Thread")+": %s%s\n", colorBold("%d", t.ID), paniced))
			if len(t.Name) > 0 {
				out.WriteString(fmt.Sprintf("    Name:           %s\n", colorTime(t.Name)))
			}
			if len(t.DispatchQueueLabel) > 0 {
				out.WriteString(fmt.Sprintf("    Queue:          %s\n", t.DispatchQueueLabel))
			}
			out.WriteString(fmt.Sprintf("    State:          %s\n", fmtState(t.State)))
			out.WriteString(fmt.Sprintf("    Base Priority:  %d\n", t.BasePriority))
			out.WriteString(fmt.Sprintf("    Sched Priority: %d\n", t.SchedPriority))
			out.WriteString(fmt.Sprintf("    User Time:      %d usec\n", t.UserUsec))
			out.WriteString(fmt.Sprintf("    System Time:    %d usec\n", t.SystemUsec))
			if len(t.UserFrames) > 0 {
				out.WriteString("    User Frames:\n")
				isPanickedThread := p.ID == panickedPID && t.ID == panickedTID
				buf := bytes.NewBufferString("")
				w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
				for idx, f := range t.UserFrames {
					if f.ImageName == "absolute" {
						continue
					}
					addr, slideVal, _ := i.panicFrameAddr(f)
					slideInfo := ""
					if slideVal > 0 {
						slideInfo = fmt.Sprintf(" (slide=%#x)", slideVal)
					}
					symloc := i.fmtSymbolLocation(f.SymbolLocation)
					fmt.Fprintf(w, "      %02d: %s\t%s%s %s%s\n", idx, colorImage(f.ImageName), colorAddr("%#x", addr), slideInfo, colorField(f.Symbol), symloc)
					// Add peek disassembly for panicked thread frames
					if i.Config.Peek && isPanickedThread && len(f.PeekBytes) > 0 {
						w.Flush()
						out.WriteString(buf.String())
						buf.Reset()
						isDSC := i.isDSCFrame(f)
						out.WriteString(formatPeekDisassembly(f.PeekBytes, f.PeekAddr, f.PeekFrameIdx, slideVal, i.Config.KernelSlide, i.Config.DSCSlide, isDSC, i.Config.Unslid, f.PeekSymbols, i.Config.Demangle))
					}
				}
				w.Flush()
				out.WriteString(buf.String())
			}
			if len(t.KernelFrames) > 0 {
				out.WriteString("    Kernel Frames:\n")
				isPanickedThread := p.ID == panickedPID && t.ID == panickedTID
				buf := bytes.NewBufferString("")
				w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
				for idx, f := range t.KernelFrames {
					if f.ImageName == "absolute" {
						continue
					}
					addr, slideVal, _ := i.panicFrameAddr(f)
					slideInfo := ""
					if slideVal > 0 {
						slideInfo = fmt.Sprintf(" (slide=%#x)", slideVal)
					}
					symloc := i.fmtSymbolLocation(f.SymbolLocation)
					fmt.Fprintf(w, "      %02d: %s\t%s%s %s%s\n", idx, colorImage(f.ImageName), colorAddr("%#x", addr), slideInfo, colorField(f.Symbol), symloc)
					// Add peek disassembly for panicked thread frames
					if i.Config.Peek && isPanickedThread && len(f.PeekBytes) > 0 {
						w.Flush()
						out.WriteString(buf.String())
						buf.Reset()
						// Kernel frames are never DSC frames, so pass false for isDSCFrame
						out.WriteString(formatPeekDisassembly(f.PeekBytes, f.PeekAddr, f.PeekFrameIdx, slideVal, i.Config.KernelSlide, i.Config.DSCSlide, false, i.Config.Unslid, f.PeekSymbols, i.Config.Demangle))
					}
				}
				w.Flush()
				out.WriteString(buf.String())
			}
		}
		out.WriteString("\n")
	}
	return out.String()
}

// watchdogString renders SystemWatchdogCrash reports (bug_type 409): a userspace
// watchdog (e.g. WindowServer) hung past its deadline and the system watchdog
// took the device down. The actionable signal is the termination block naming
// the service that timed out; the nested stackshot follows for context.
func (i *Ips) watchdogString() string {
	var out strings.Builder
	out.WriteString(i.headerLine())

	if len(i.Payload.Termination.Namespace) > 0 || len(i.Payload.Termination.Details) > 0 {
		out.WriteString(colorField("Termination") + ":\n")
		if len(i.Payload.Termination.Namespace) > 0 {
			out.WriteString(fmt.Sprintf("  Namespace:  %s\n", colorError(i.Payload.Termination.Namespace)))
		}
		if i.Payload.Termination.Code != 0 {
			out.WriteString(fmt.Sprintf("  Code:       %d\n", i.Payload.Termination.Code))
		}
		if len(i.Payload.Termination.Indicator) > 0 {
			out.WriteString(fmt.Sprintf("  Indicator:  %s\n", i.Payload.Termination.Indicator))
		}
		for _, d := range i.Payload.Termination.Details {
			out.WriteString(fmt.Sprintf("  - %s\n", colorError(d)))
		}
		out.WriteString("\n")
	}

	buf := bytes.NewBufferString("")
	w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
	if len(i.Payload.ProcName) > 0 {
		fmt.Fprintf(w, "%s\t%s [%d]\n", colorField("Process"), colorImage("%s", i.Payload.ProcName), i.Payload.PID)
	}
	if len(i.Payload.DisplayState) > 0 {
		fmt.Fprintf(w, "%s\t%s\n", colorField("Display State"), i.Payload.DisplayState)
	}
	if len(i.Payload.ThermalPressureLevel) > 0 {
		fmt.Fprintf(w, "%s\t%s\n", colorField("Thermal Level"), i.Payload.ThermalPressureLevel)
	}
	w.Flush()
	if buf.Len() > 0 {
		out.WriteString(buf.String() + "\n")
	}

	notes := i.Payload.ReportNotes
	if !i.Config.Verbose {
		notes = filterStackshotNoise(notes)
	}
	if len(notes) > 0 {
		out.WriteString(colorField("Report Notes") + ":\n")
		for _, n := range notes {
			out.WriteString(fmt.Sprintf("  - %s\n", n))
		}
		out.WriteString("\n")
	}

	// Apple sometimes emits a top-level pid of -1 for watchdog timeouts; identify
	// the offending process by name so its threads still render (lowest matching
	// pid for a deterministic pick).
	markedPID := i.Payload.PID
	if markedPID < 0 && len(i.Payload.ProcName) > 0 {
		for pid, p := range i.Payload.ProcessByPid {
			if p.Name == i.Payload.ProcName && (markedPID < 0 || pid < markedPID) {
				markedPID = pid
			}
		}
	}
	out.WriteString(i.processDumpString(markedPID, -1))
	return out.String()
}

// filterStackshotNoise drops the per-PID stackshot resampling diagnostics
// ("task_read_for_pid(...) failed", "resampled N of M ...", "... unindexed ...
// frames from ... pids") that dominate a watchdog report's reportNotes but carry
// no signal. The remaining notes (e.g. the watchdog explanation) are kept.
func filterStackshotNoise(notes []string) []string {
	var out []string
	for _, n := range notes {
		if strings.HasPrefix(n, "task_read_for_pid(") ||
			strings.HasPrefix(n, "resampled ") ||
			strings.Contains(n, "unindexed") {
			continue
		}
		out = append(out, n)
	}
	return out
}

// crashString renders userspace crash reports (bug_type 309).
func (i *Ips) crashString() string {
	out := fmt.Sprintf("[%s] - %s\n\n", colorTime(i.Header.Timestamp.Format("02Jan2006 15:04:05")), colorError(i.Header.BugTypeDesc))
	out += fmt.Sprintf(
		colorField("Process")+":        %s [%d]\n"+
			colorField("Path")+":           %s\n"+
			colorField("Parent")+":         %s [%d]\n"+
			colorField("Hardware Model")+": %s\n"+
			colorField("OS Version")+":     %s\n"+
			colorField("BuildID")+":        %s\n"+
			colorField("LockdownMode")+":            %d\n"+
			colorField("Was Unlocked Since Boot")+": %d\n"+
			colorField("Is Locked")+":               %d\n",
		i.Payload.ProcName, i.Payload.PID,
		i.Payload.ProcPath,
		i.Payload.ParentProc, i.Payload.ParentPid,
		i.Payload.ModelCode,
		i.Payload.OsVersion.Train,
		i.Payload.OsVersion.Build,
		i.Payload.LockdownMode,
		i.Payload.WasUnlockedSinceBoot,
		i.Payload.IsLocked,
	)
	if i.Payload.SharedCache.Size > 0 {
		out += fmt.Sprintf(colorField("Shared Cache")+":        %s %s: %#x %s: %d\n", i.Payload.SharedCache.UUID, colorField("base"), i.Payload.SharedCache.Base, colorField("size"), i.Payload.SharedCache.Size)
	}
	var exception Exception
	if err := mapstructure.Decode(i.Payload.Exception, &exception); err == nil {
		out += fmt.Sprintf("\n%s:      %s (%s) %s\n", colorField("Exception Type"), colorBold(exception.Type), exception.Signal, exception.Message)
		if len(exception.Subtype) > 0 {
			out += fmt.Sprintf(colorField("Exception Subtype")+":   %s\n", exception.Subtype)
		}
	} else {
		out += fmt.Sprintf(colorField("Exception")+": %s\n", i.Payload.Exception)
	}
	if len(i.Payload.VmRegionInfo) > 0 {
		out += fmt.Sprintf(colorField("VM Region Info")+": %s\n", i.Payload.VmRegionInfo)
	}
	if i.Payload.Asi != nil {
		out += colorField("ASI") + ":\n"
		for k, v := range i.Payload.Asi {
			out += fmt.Sprintf("    %s:\n", k)
			for _, s := range v {
				out += fmt.Sprintf("      - %s\n", s)
			}
		}
		out += "\n"
	}
	for _, t := range i.Payload.Threads {
		out += fmt.Sprintf(colorField("Thread")+" %s:", colorBold("%d", t.ID))
		if len(t.Name) > 0 {
			out += colorField(" name") + ": " + t.Name
			if len(t.Queue) > 0 {
				out += ","
			}
		}
		if len(t.Queue) > 0 {
			out += colorField(" queue") + ": " + t.Queue
		}
		if t.Triggered {
			out += colorError(" (Crashed)\n")
		} else {
			out += "\n"
		}
		if len(t.Frames) > 0 {
			buf := bytes.NewBufferString("")
			w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
			for idx, f := range t.Frames {
				addr, name, slideInfo := i.crashFrameAddr(f)
				symloc := i.fmtSymbolLocation(f.SymbolLocation)
				fmt.Fprintf(w, "  %02d: %s\t%s%s %s%s\n", idx, colorImage(name), colorAddr("%#x", addr), slideInfo, colorField(f.Symbol), symloc)
			}
			w.Flush()
			out += buf.String()
		}
		if t.Triggered || i.Config.All {
			out += colorField("Thread") + colorBold(" %d ", t.ID) + colorField(t.ThreadState.Flavor) + "\n" + t.ThreadState.String()
			if t.Triggered {
				if len(i.Payload.InstructionByteStream.BeforePC) > 0 {
					out += colorField("Instructions") + "\n"
					if b64data, err := base64.StdEncoding.WithPadding(base64.StdPadding).DecodeString(i.Payload.InstructionByteStream.BeforePC); err != nil {
						log.WithError(err).Errorf("failed to decode BeforePC instruction byte stream")
					} else {
						if instructions, err := disassemble.GetInstructions(t.ThreadState.PC.Value-(10*4), b64data); err != nil {
							log.WithError(err).Errorf("failed to disassemble BeforePC instructions")
						} else {
							pad := "    "
							for _, block := range instructions.Blocks() {
								for _, i := range block {
									opStr := strings.TrimSpace(strings.TrimPrefix(i.String(), i.Operation.String()))
									out += fmt.Sprintf("%s%s:  %s   %s %s\n",
										pad,
										colorAddr("%#08x", i.Address),
										disassemble.GetOpCodeByteString(i.Raw),
										colorImage("%-7s", i.Operation),
										disass.ColorOperands(opStr),
									)
								}
								out += "\n"
							}
						}
					}
				}
				if len(i.Payload.InstructionByteStream.AtPC) > 0 {
					out = strings.TrimSuffix(out, "\n")
					if b64data, err := base64.StdEncoding.WithPadding(base64.StdPadding).DecodeString(i.Payload.InstructionByteStream.AtPC); err != nil {
						log.WithError(err).Errorf("failed to decode AtPC instruction byte stream")
					} else {
						if instructions, err := disassemble.GetInstructions(t.ThreadState.PC.Value, b64data); err != nil {
							log.WithError(err).Errorf("failed to disassemble AtPC instructions")
						} else {
							for idx, block := range instructions.Blocks() {
								for jdx, i := range block {
									pad := "    "
									if idx == 0 && jdx == 0 {
										pad = colorError("PC=>")
									}
									opStr := strings.TrimSpace(strings.TrimPrefix(i.String(), i.Operation.String()))
									out += fmt.Sprintf("%s%s:  %s   %s %s\n",
										pad,
										colorAddr("%#08x", i.Address),
										disassemble.GetOpCodeByteString(i.Raw),
										colorImage("%-7s", i.Operation),
										disass.ColorOperands(opStr),
									)
								}
								out += "\n"
							}
						}
					}
				}
			}
		}
		out += "\n"
	}
	if len(i.Payload.LastExceptionBacktrace) > 0 {
		out += colorField("Last Exception Backtrace") + ":\n"
		buf := bytes.NewBufferString("")
		w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
		for idx, f := range i.Payload.LastExceptionBacktrace {
			addr, name, slideInfo := i.crashFrameAddr(f)
			symloc := i.fmtSymbolLocation(f.SymbolLocation)
			fmt.Fprintf(w, "  %02d: %s\t%s%s %s%s\n", idx, colorImage(name), colorAddr("%#x", addr), slideInfo, colorField(f.Symbol), symloc)
		}
		w.Flush()
		out += buf.String() + "\n"
	}
	if len(i.Payload.VMSummary) > 0 {
		out += colorField("VM Summary") + ":\n" + colorVMSummary(i.Payload.VMSummary)
	}
	return out
}
