/*
 * Copyright 2018 faddenSoft
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;

using Asm65;
using CommonUtil;
using SourceGen.Sandbox;

namespace SourceGen {
    /// <summary>
    /// All state for an open project.
    /// 
    /// This class does no file I/O or user interaction.
    /// </summary>
    public class DisasmProject {
        // Arbitrary 1MB limit.  Could be increased to 16MB if performance is acceptable.
        public const int MAX_DATA_FILE_SIZE = 1 << 20;

        // File magic.
        private const long MAGIC = 6982516645493599905;


        #region Data that is saved and restored
        // All data held by structures in this section are persistent, and will be
        // written to the project file.  Anything not in this section may be discarded
        // at any time.  Smaller items are kept in arrays, with one entry per byte
        // of file data.

        // Length of input data.  (This is redundant with FileData.Length while in memory,
        // but we want this value to be serialized into the project file.)
        public int FileDataLength { get; private set; }

        // CRC-32 on input data.
        public uint FileDataCrc32 { get; private set; }

        // Map file offsets to addresses.
        public AddressMap AddrMap { get { return mAddrMap; } }
        private AddressMap mAddrMap;

        // Type hints.  Default value is "no hint".
        public CodeAnalysis.TypeHint[] TypeHints { get; private set; }

        // Status flag overrides.  Default value is "all unspecified".
        public StatusFlags[] StatusFlagOverrides { get; private set; }

        // End-of-line comments.  Empty string means "no comment".
        public string[] Comments { get; private set; }

        // Full line, possibly multi-line comments.
        public Dictionary<int, MultiLineComment> LongComments { get; private set; }

        // Notes, which are like comments but not included in the assembled output.
        public SortedList<int, MultiLineComment> Notes { get; private set; }

        // Labels, defined by the user; uses file offset as key.  Ideally the label names
        // are unique, but there are ways around that.
        public Dictionary<int, Symbol> UserLabels { get; private set; }

        // Format descriptors for operands and data items; uses file offset as key.
        public SortedList<int, FormatDescriptor> OperandFormats { get; private set; }

        // Project properties.  Includes CPU type, platform symbol file names, project
        // symbols, etc.
        public ProjectProperties ProjectProps { get; private set; }

        #endregion // data to save & restore


        /// <summary>
        /// The contents of the 65xx data file.
        /// </summary>
        public byte[] FileData { get { return mFileData; } }
        private byte[] mFileData;

        /// <summary>
        /// CPU definition to use when analyzing input.
        /// </summary>
        public CpuDef CpuDef { get; private set; }

        /// <summary>
        /// If true, plugins will execute in the main application's AppDomain instead of
        /// the sandbox.  Must be set before calling Initialize().
        /// </summary>
        public bool UseMainAppDomainForPlugins { get; set; }

        /// <summary>
        /// Full pathname of project file.  The directory name is needed when loading
        /// platform symbols and extension scripts from the project directory, and the
        /// filename is used to give project-local extension scripts unique DLL names.
        ///
        /// For a new project that hasn't been saved yet, this will be empty.
        /// </summary>
        public string ProjectPathName { get; set; }

        // Name of data file.  This is used for debugging and when naming DLLs for
        // project-local extension scripts.  Just the filename, not the path.
        private string mDataFileName;

        // This holds working state for the code and data analyzers.  Some of the state
        // is presented directly to the user, e.g. status flags.  All of the data here
        // should be considered transient; it may be discarded at any time without
        // causing user data loss.
        private Anattrib[] mAnattribs;

        // A snapshot of the Anattribs array, taken after code analysis has completed,
        // before data analysis has begun.
        private Anattrib[] mCodeOnlyAnattribs;

        // Symbol lists loaded from platform symbol files.  This is essentially a list
        // of lists, of symbols.
        private List<PlatformSymbols> PlatformSyms { get; set; }

        // Extension script manager.  Controls AppDomain sandbox.
        private ScriptManager mScriptManager;

        // All symbols, including user-defined, platform-specific, and auto-generated, keyed by
        // label string.  This is rebuilt whenever we do a refresh, and modified whenever
        // labels or platform definitions are edited.
        //
        // Note this includes project/platform symbols that will not be in the assembled output.
        public SymbolTable SymbolTable { get; private set; }

        // Cross-reference data, indexed by file offset.
        private Dictionary<int, XrefSet> mXrefs = new Dictionary<int, XrefSet>();

        // Project and platform symbols that are being referenced from code.
        public List<DefSymbol> ActiveDefSymbolList { get; private set; }

        // List of changes for undo/redo.
        private List<ChangeSet> mUndoList = new List<ChangeSet>();

        // Index of slot where next undo operation will be placed.
        private int mUndoTop = 0;

        // Index of top when the file was last saved.
        private int mUndoSaveIndex = 0;


        /// <summary>
        /// Constructs a new project.
        /// </summary>
        public DisasmProject() { }

        /// <summary>
        /// Prepares the object by instantiating various fields, some of which are sized to
        /// match the length of the data file.  The data file may not have been loaded yet
        /// (e.g. when deserializing a project file).
        /// </summary>
        public void Initialize(int fileDataLen) {
            Debug.Assert(FileDataLength == 0);      // i.e. Initialize() hasn't run yet
            Debug.Assert(fileDataLen > 0);

            FileDataLength = fileDataLen;
            ProjectPathName = string.Empty;

            mAddrMap = new AddressMap(fileDataLen);
            mAddrMap.Set(0, 0x1000);    // default load address to $1000; override later

            // Default value is "no hint".
            TypeHints = new CodeAnalysis.TypeHint[fileDataLen];

            // Default value is "unspecified" for all bits.
            StatusFlagOverrides = new StatusFlags[fileDataLen];

            Comments = new string[fileDataLen];

            // Populate with empty strings so we don't have to worry about null refs.
            for (int i = 0; i < Comments.Length; i++) {
                Comments[i] = string.Empty;
            }

            LongComments = new Dictionary<int, MultiLineComment>();
            Notes = new SortedList<int, MultiLineComment>();

            UserLabels = new Dictionary<int, Symbol>();
            OperandFormats = new SortedList<int, FormatDescriptor>();
            ProjectProps = new ProjectProperties();

            SymbolTable = new SymbolTable();
            PlatformSyms = new List<PlatformSymbols>();
            ActiveDefSymbolList = new List<DefSymbol>();

            // Default to 65816.  This will be replaced with value from project file or
            // system definition.
            ProjectProps.CpuType = CpuDef.CpuType.Cpu65816;
            ProjectProps.IncludeUndocumentedInstr = false;
            UpdateCpuDef();
        }

        /// <summary>
        /// Discards resources, notably the sandbox AppDomain.
        /// </summary>
        public void Cleanup() {
            Debug.WriteLine("DisasmProject.Cleanup(): scriptMgr=" + mScriptManager);
            if (mScriptManager != null) {
                mScriptManager.Cleanup();
                mScriptManager = null;
            }
        }

        /// <summary>
        /// Prepares the DisasmProject for use as a new project.
        /// </summary>
        /// <param name="fileData">65xx data file contents.</param>
        /// <param name="dataFileName">Data file's filename (not pathname).</param>
        public void PrepForNew(byte[] fileData, string dataFileName) {
            Debug.Assert(fileData.Length == FileDataLength);

            mFileData = fileData;
            mDataFileName = dataFileName;
            FileDataCrc32 = CommonUtil.CRC32.OnWholeBuffer(0, mFileData);

            // Mark the first byte as code so we have something to do.  This may get
            // overridden later.
            TypeHints[0] = CodeAnalysis.TypeHint.Code;
        }

        /// <summary>
        /// Pulls items of interest out of the system definition object and applies them
        /// to the project.  Call this after LoadDataFile() for a new project.
        /// </summary>
        /// <param name="sysDef">Target system definition.</param>
        public void ApplySystemDef(Setup.SystemDef sysDef) {
            CpuDef.CpuType cpuType = CpuDef.GetCpuTypeFromName(sysDef.Cpu);
            bool includeUndoc = Setup.SystemDefaults.GetUndocumentedOpcodes(sysDef);
            CpuDef tmpDef = CpuDef.GetBestMatch(cpuType, includeUndoc);

            // Store the best-matched CPU in properties, rather than whichever was originally
            // requested.  This way the behavior of the project is the same for everyone, even
            // if somebody has a newer app version with specialized handling for the
            // originally-specified CPU.
            ProjectProps.CpuType = tmpDef.Type;
            ProjectProps.IncludeUndocumentedInstr = includeUndoc;
            UpdateCpuDef();

            ProjectProps.EntryFlags = Setup.SystemDefaults.GetEntryFlags(sysDef);

            // Configure the load address.
            if (Setup.SystemDefaults.GetFirstWordIsLoadAddr(sysDef) && mFileData.Length > 2) {
                // First two bytes are the load address, code starts at offset +000002.  We
                // need to put the load address into the stream, but don't want it to get
                // picked up as an address for something else.  So we set it to the same
                // address as the start of the file.  The overlapping-address code should do
                // the right thing with it.
                int loadAddr = RawData.GetWord(mFileData, 0, 2, false);
                mAddrMap.Set(0, loadAddr);
                mAddrMap.Set(2, loadAddr);
                OperandFormats[0] = FormatDescriptor.Create(2, FormatDescriptor.Type.NumericLE,
                    FormatDescriptor.SubType.None);
                TypeHints[0] = CodeAnalysis.TypeHint.NoHint;
                TypeHints[2] = CodeAnalysis.TypeHint.Code;
            } else {
                int loadAddr = Setup.SystemDefaults.GetLoadAddress(sysDef);
                mAddrMap.Set(0, loadAddr);
            }

            foreach (string str in sysDef.SymbolFiles) {
                ProjectProps.PlatformSymbolFileIdentifiers.Add(str);
            }
            foreach (string str in sysDef.ExtensionScripts) {
                ProjectProps.ExtensionScriptFileIdentifiers.Add(str);
            }
        }

        public void UpdateCpuDef() {
            CpuDef = CpuDef.GetBestMatch(ProjectProps.CpuType,
                ProjectProps.IncludeUndocumentedInstr);
        }

        /// <summary>
        /// Sets the file CRC.  Called during deserialization.
        /// </summary>
        /// <param name="crc">Data file CRC.</param>
        public void SetFileCrc(uint crc) {
            Debug.Assert(FileDataLength > 0);
            FileDataCrc32 = crc;
        }

        /// <summary>
        /// Sets the file data array.  Used when the project is created from a project file.
        /// </summary>
        /// <param name="fileData">65xx data file contents.</param>
        /// <param name="dataFileName">Data file's filename (not pathname).</param>
        public void SetFileData(byte[] fileData, string dataFileName) {
            Debug.Assert(fileData.Length == FileDataLength);
            Debug.Assert(CRC32.OnWholeBuffer(0, fileData) == FileDataCrc32);
            mFileData = fileData;
            mDataFileName = dataFileName;
        }

        /// <summary>
        /// Loads platform symbol files and extension scripts.
        /// 
        /// Call this on initial load and whenever the set of platform symbol files changes
        /// in the project config.
        /// 
        /// Failures here will be reported to the user but aren't fatal.
        /// </summary>
        /// <returns>String with all warnings from load process.</returns>
        public string LoadExternalFiles() {
            TaskTimer timer = new TaskTimer();
            timer.StartTask("Total");

            StringBuilder sb = new StringBuilder();

            string projectDir = string.Empty;
            if (!string.IsNullOrEmpty(ProjectPathName)) {
                projectDir = Path.GetDirectoryName(ProjectPathName);
            }

            // Load the platform symbols first.
            timer.StartTask("Platform Symbols");
            PlatformSyms.Clear();
            foreach (string fileIdent in ProjectProps.PlatformSymbolFileIdentifiers) {
                PlatformSymbols ps = new PlatformSymbols();
                bool ok = ps.LoadFromFile(fileIdent, projectDir, out FileLoadReport report);
                if (ok) {
                    PlatformSyms.Add(ps);
                }
                if (report.Count > 0) {
                    sb.Append(report.Format());
                }
            }
            timer.EndTask("Platform Symbols");

            // Instantiate the script manager on first use.
            timer.StartTask("Create ScriptManager");
            if (mScriptManager == null) {
                mScriptManager = new ScriptManager(this);
            } else {
                mScriptManager.Clear();
            }
            timer.EndTask("Create ScriptManager");

            // Load the extension script files.
            timer.StartTask("Load Extension Scripts");
            foreach (string fileIdent in ProjectProps.ExtensionScriptFileIdentifiers) {
                bool ok = mScriptManager.LoadPlugin(fileIdent, out FileLoadReport report);
                if (report.Count > 0) {
                    sb.Append(report.Format());
                }
            }
            timer.EndTask("Load Extension Scripts");

            timer.EndTask("Total");
            timer.DumpTimes("Time to load external files:");

            return sb.ToString();
        }

        /// <summary>
        /// Analyzes the file data.  This is the main entry point for code/data analysis.
        /// </summary>
        /// <param name="reanalysisRequired">How much work to do.</param>
        /// <param name="debugLog">Object to send debug output to.</param>
        /// <param name="reanalysisTimer">Task timestamp collection object.</param>
        public void Analyze(UndoableChange.ReanalysisScope reanalysisRequired,
                CommonUtil.DebugLog debugLog, TaskTimer reanalysisTimer) {
            // This method doesn't report failures.  It succeeds to the best of its ability,
            // and handles problems by discarding bad data.  The overall philosophy is that
            // the program will never generate bad data, and any bad project file contents
            // (possibly introduced by hand-editing) are identified at load time, called out
            // to the user, and discarded.
            Debug.Assert(reanalysisRequired != UndoableChange.ReanalysisScope.None);
            reanalysisTimer.StartTask("DisasmProject.Analyze()");

            // Populate the symbol table with platform symbols, in file load order, then
            // merge in the project symbols, potentially replacing platform symbols that
            // have the same label.  This version of the table is passed to plugins during
            // code analysis.
            reanalysisTimer.StartTask("SymbolTable init");
            SymbolTable.Clear();
            MergePlatformProjectSymbols();
            // Merge user labels into the symbol table, overwriting platform/project symbols
            // where they conflict.  Labels whose values are out of sync (because of a change
            // to the address map) are updated as part of this.
            UpdateAndMergeUserLabels();
            reanalysisTimer.EndTask("SymbolTable init");

            if (reanalysisRequired == UndoableChange.ReanalysisScope.CodeAndData) {
                // Always want to start with a blank array.  Going to be lazy and let the
                // system allocator handle that for us.
                mAnattribs = new Anattrib[mFileData.Length];

                reanalysisTimer.StartTask("CodeAnalysis.Analyze");

                CodeAnalysis ca = new CodeAnalysis(mFileData, CpuDef, mAnattribs, mAddrMap,
                    TypeHints, StatusFlagOverrides, ProjectProps.EntryFlags, mScriptManager,
                    debugLog);

                ca.Analyze();
                reanalysisTimer.EndTask("CodeAnalysis.Analyze");

                // Save a copy of the current state.
                mCodeOnlyAnattribs = new Anattrib[mAnattribs.Length];
                Array.Copy(mAnattribs, mCodeOnlyAnattribs, mAnattribs.Length);
            } else {
                // Load Anattribs array from the stored copy.
                Debug.WriteLine("Partial reanalysis");
                reanalysisTimer.StartTask("CodeAnalysis (restore prev)");
                Debug.Assert(mCodeOnlyAnattribs != null);
                Array.Copy(mCodeOnlyAnattribs, mAnattribs, mAnattribs.Length);
                reanalysisTimer.EndTask("CodeAnalysis (restore prev)");
            }

            reanalysisTimer.StartTask("Apply labels, formats, etc.");
            // Apply any user-defined labels to the Anattribs array.
            ApplyUserLabels(debugLog);

            // Apply user-created format descriptors to instructions and data items.
            ApplyFormatDescriptors(debugLog);
            reanalysisTimer.EndTask("Apply labels, formats, etc.");

            reanalysisTimer.StartTask("DataAnalysis");
            DataAnalysis da = new DataAnalysis(this, mAnattribs);
            da.DebugLog = debugLog;

            reanalysisTimer.StartTask("DataAnalysis.AnalyzeDataTargets");
            da.AnalyzeDataTargets();
            reanalysisTimer.EndTask("DataAnalysis.AnalyzeDataTargets");

            // Analyze uncategorized regions.  When this completes, the Anattrib array will
            // be complete for every offset, and the file will be traversible by walking
            // through the lengths of each entry.
            reanalysisTimer.StartTask("DataAnalysis.AnalyzeUncategorized");
            da.AnalyzeUncategorized();
            reanalysisTimer.EndTask("DataAnalysis.AnalyzeUncategorized");

            reanalysisTimer.EndTask("DataAnalysis");

            reanalysisTimer.StartTask("RemoveHiddenLabels");
            RemoveHiddenLabels();
            reanalysisTimer.EndTask("RemoveHiddenLabels");


            // ----------
            // NOTE: we could add an additional re-analysis entry point here, that just deals with
            // platform symbols and xrefs, to be used after a change to project symbols.  We'd
            // need to check all existing refs to confirm that the symbol hasn't been removed.
            // Symbol updates are sufficiently infrequent that this probably isn't worthwhile.

            // NOTE: we could at this point apply platform address symbols as code labels, so
            // that locations in the code that correspond to well-known addresses would pick
            // up the appropriate label instead of getting auto-labeled.  It's unclear
            // whether this is desirable, especially if the user is planning to modify the
            // output later on, and it could mess things up if we start slapping
            // labels into the middle of data regions.  It's generally safer to treat
            // platform symbols as labels for constants and external references.  If somebody
            // finds an important use case we can revisit this; might merit a special type
            // of equate or section in the platform symbol definition file.

            reanalysisTimer.StartTask("GeneratePlatformSymbolRefs");
            // Generate references to platform and project external symbols.
            GeneratePlatformSymbolRefs();
            reanalysisTimer.EndTask("GeneratePlatformSymbolRefs");

            reanalysisTimer.StartTask("GenerateXrefs");
            // Generate cross-reference lists.
            mXrefs.Clear();
            GenerateXrefs();
            reanalysisTimer.EndTask("GenerateXrefs");

            reanalysisTimer.StartTask("GenerateActiveDefSymbolList");
            // Generate the list of project/platform symbols that are being used.
            GenerateActiveDefSymbolList();
            reanalysisTimer.EndTask("GenerateActiveDefSymbolList");

            Validate();

            reanalysisTimer.EndTask("DisasmProject.Analyze()");
            //reanalysisTimer.DumpTimes("DisasmProject timers:", debugLog);

            debugLog.LogI("Analysis complete");
        }

        /// <summary>
        /// Applies user labels to the Anattribs array.  Symbols with stale Value fields will
        /// be replaced.
        /// </summary>
        /// <param name="genLog">Log for debug messages.</param>
        private void ApplyUserLabels(DebugLog genLog) {
            foreach (KeyValuePair<int, Symbol> kvp in UserLabels) {
                int offset = kvp.Key;
                if (offset < 0 || offset >= mAnattribs.Length) {
                    genLog.LogE("Invalid offset +" + offset.ToString("x6") +
                        "(label=" + kvp.Value.Label + ")");
                    continue;       // ignore this
                }

                if (mAnattribs[offset].Symbol != null) {
                    genLog.LogW("Multiple labels at offset +" + offset.ToString("x6") +
                        ": " + kvp.Value.Label + " / " + mAnattribs[offset].Symbol.Label);
                    continue;
                }

                int expectedAddr = kvp.Value.Value;
                Debug.Assert(expectedAddr == mAddrMap.OffsetToAddress(offset));

                // Add direct reference to the UserLabels Symbol object.
                mAnattribs[offset].Symbol = kvp.Value;
            }
        }

        /// <summary>
        /// Applies user-defined format descriptors to the Anattribs array.  This specifies the
        /// format for instruction operands, and identifies data items.
        /// </summary>
        /// <param name="genLog">Log for debug messages.</param>
        private void ApplyFormatDescriptors(DebugLog genLog) {
            foreach (KeyValuePair<int, FormatDescriptor> kvp in OperandFormats) {
                int offset = kvp.Key;

                // If you hint as data, apply formats, and then hint as code, all sorts
                // of strange things can happen.  We want to ignore anything that doesn't
                // appear to be valid.  While we're at it, we do some internal consistency
                // checks in the name of catching bugs as soon as possible.

                // Check offset.
                if (offset < 0 || offset >= mAnattribs.Length) {
                    genLog.LogE("Invalid offset +" + offset.ToString("x6") +
                        "(desc=" + kvp.Value + ")");
                    Debug.Assert(false);
                    continue;       // ignore this one
                }

                // Make sure it doesn't run off the end
                if (offset + kvp.Value.Length > mAnattribs.Length) {
                    genLog.LogE("Invalid offset+len +" + offset.ToString("x6") +
                        " len=" + kvp.Value.Length + " file=" + mAnattribs.Length);
                    Debug.Assert(false);
                    continue;       // ignore this one
                }

                if (mAnattribs[offset].IsInstructionStart) {
                    // Check length for instruction formatters.  This can happen if you format
                    // a bunch of bytes as single-byte data items and then add a code entry
                    // point.
                    if (kvp.Value.Length != mAnattribs[offset].Length) {
                        genLog.LogW("+" + offset.ToString("x6") +
                            ": unexpected length on instr format descriptor (" +
                            kvp.Value.Length + " vs " + mAnattribs[offset].Length + ")");
                        continue;       // ignore this one
                    }
                    if (kvp.Value.Length == 1) {
                        // No operand to format!
                        genLog.LogW("+" + offset.ToString("x6") +
                            ": unexpected format descriptor on single-byte op");
                        continue;       // ignore this one
                    }
                    if (!kvp.Value.IsValidForInstruction) {
                        genLog.LogW("Descriptor not valid for instruction: " + kvp.Value);
                        continue;       // ignore this one
                    }
                } else if (mAnattribs[offset].IsInstruction) {
                    // Mid-instruction format.
                    genLog.LogW("+" + offset.ToString("x6") +
                        ": unexpected mid-instruction format descriptor");
                    continue;       // ignore this one
                }

                mAnattribs[offset].DataDescriptor = kvp.Value;
            }
        }

        /// <summary>
        /// Merges symbols from PlatformSymbols and ProjectSymbols into SymbolTable.
        ///
        /// This should be done before any other symbol assignment or generation, so that user
        /// labels take precedence (by virtue of overwriting the earlier platform symbols),
        /// and auto label generation can propery generate a unique label.
        ///
        /// Within platform symbol loading, later symbols should replace earlier symbols,
        /// so that ordering of platform files behaves in an intuitive fashion.
        /// </summary>
        private void MergePlatformProjectSymbols() {
            // Start by pulling in the platform symbols.
            foreach (PlatformSymbols ps in PlatformSyms) {
                foreach (Symbol sym in ps) {
                    SymbolTable[sym.Label] = sym;
                }
            }

            // Now add project symbols, overwriting platform symbols with the same label.
            foreach (KeyValuePair<string, DefSymbol> kvp in ProjectProps.ProjectSyms) {
                SymbolTable[kvp.Value.Label] = kvp.Value;
            }
        }

        /// <summary>
        /// Merges symbols from UserLabels into SymbolTable.  Existing entries with matching
        /// labels will be replaced.
        /// </summary>
        private void UpdateAndMergeUserLabels() {
            // We store symbols as label+value, but for a user label the actual value is
            // the address of the offset the label is associated with.  It's convenient
            // to store labels as Symbols because we also want the Type value, and it avoids
            // having to create Symbol objects on the fly.  If the value in the UserLabel
            // is wrong, we fix it here.

            Dictionary<int, Symbol> changes = new Dictionary<int, Symbol>();

            foreach (KeyValuePair<int, Symbol> kvp in UserLabels) {
                int offset = kvp.Key;
                Symbol sym = kvp.Value;
                int expectedAddr = mAddrMap.OffsetToAddress(offset);
                if (sym.Value != expectedAddr) {
                    Symbol newSym = new Symbol(sym.Label, expectedAddr, sym.SymbolSource,
                        sym.SymbolType);
                    Debug.WriteLine("Replacing label sym: " + sym + " --> " + newSym);
                    changes[offset] = newSym;
                    sym = newSym;
                }
                SymbolTable[kvp.Value.Label] = sym;
            }

            // If we updated any symbols, merge the changes back into UserLabels.
            if (changes.Count != 0) {
                Debug.WriteLine("...merging " + changes.Count + " symbols into UserLabels");
            }
            foreach (KeyValuePair<int, Symbol> kvp in changes) {
                UserLabels[kvp.Key] = kvp.Value;
            }
        }

        /// <summary>
        /// Removes user labels from the symbol table if they're in the middle of an
        /// instruction or multi-byte data area.  (Easy way to cause this: hint a 3-byte
        /// instruction as data, add a label to the middle byte, remove hints.)
        /// 
        /// Call this after the code and data analysis passes have completed.  Any
        /// references to the hidden labels will just fall through.  It will be possible
        /// to create multiple labels with the same name, because the app won't see them
        /// in the symbol table.
        /// </summary>
        private void RemoveHiddenLabels() {
            // TODO(someday): keep the symbols in the symbol table so we can't create a
            //   duplicate, but flag it as hidden.  The symbol resolver will need to know
            //   to ignore it.  Provide a way for users to purge them.  We could just blow
            //   them out of UserLabels right now, but I'm trying to avoid discarding user-
            //   created data without permission.
            foreach (KeyValuePair<int, Symbol> kvp in UserLabels) {
                int offset = kvp.Key;
                if (!mAnattribs[offset].IsStart) {
                    Debug.WriteLine("Stripping hidden label '" + kvp.Value.Label + "'");
                    SymbolTable.Remove(kvp.Value);
                }
            }
        }

        /// <summary>
        /// Generates references to symbols in the project/platform symbol tables.
        /// 
        /// For each instruction or data item that appears to reference an address, and
        /// does not have a target offset, look for a matching address in the symbol tables.
        /// 
        /// This works pretty well for addresses, but is a little rough for constants.
        /// 
        /// Call this after the code and data analysis passes have completed.  This doesn't
        /// interact with labels, so the ordering there doesn't matter.
        /// </summary>
        public void GeneratePlatformSymbolRefs() {
            bool checkNearby = ProjectProps.AnalysisParams.SeekNearbyTargets;

            for (int offset = 0; offset < mAnattribs.Length; ) {
                Anattrib attr = mAnattribs[offset];
                if (attr.IsInstructionStart && attr.DataDescriptor == null &&
                        attr.OperandAddress >= 0 && attr.OperandOffset < 0) {
                    // Has an operand address, but not an offset, meaning it's a reference
                    // to an address outside the scope of the file. See if it has a
                    // platform symbol definition.
                    //
                    // It might seem unwise to examine the full symbol table, because it has
                    // non-project non-platform symbols in it.  However, any matching user
                    // labels would have been applied already.  Also, we want to ensure that
                    // conflicting user labels take precedence, e.g. creating a user label "COUT"
                    // will prevent a platform symbol with the same name from being visible.
                    // Using the full symbol table is potentially a tad less efficient than
                    // looking for a match exclusively in project/platform symbols, but it's
                    // the correct thing to do.
                    Symbol sym = SymbolTable.FindAddressByValue(attr.OperandAddress);

                    // If we didn't find it, check addr-1.  This is very helpful when working
                    // with pointers, because it gets us references to "PTR+1" when "PTR" is
                    // defined.  (It's potentially helpful in labeling the "near side" of an
                    // address map split as well, since the first byte past is an external
                    // address, and a label at the end of the current region will be offset
                    // from by this.)
                    if (sym == null && (attr.OperandAddress & 0xffff) != 0xffff && checkNearby) {
                        sym = SymbolTable.FindAddressByValue(attr.OperandAddress - 1);
                    }
                    // TODO(maybe): if this is a 65816, check for a symbol at addr-2, for the
                    //   benefit of long pointers.
                    if (sym != null) {
                        mAnattribs[offset].DataDescriptor =
                            FormatDescriptor.Create(mAnattribs[offset].Length,
                                new WeakSymbolRef(sym.Label, WeakSymbolRef.Part.Low), false);

                        // Used to do this here; now do it in GenerateXrefs() so we can
                        // pick up user-edited operand formats that reference project symbols.
                        //(sym as DefSymbol).Xrefs.Add(new XrefSet.Xref(offset,
                        //    XrefSet.XrefType.NameReference, 0));
                    }
                }

                if (attr.IsDataStart || attr.IsInlineDataStart) {
                    offset += attr.Length;
                } else {
                    // Advance by one, not attr.Length, so we don't miss embedded instructions.
                    offset++;
                }
            }
        }

        /// <summary>
        /// Generates labels for branch and data targets, and xref lists for all referenced
        /// offsets.  Also generates Xref entries for DefSymbols (for .eq directives).
        /// 
        /// Call this after the code and data analysis passes have completed.
        /// </summary>
        public void GenerateXrefs() {
            // Xref generation.  There are two general categories of references:
            //  (1) Numeric reference.  Comes from instructions (e.g. "LDA $1000" or "BRA $1000")
            //      and Numeric/Address data items.
            //  (2) Symbolic reference.  Comes from instructions and data with Symbol format
            //      descriptors.  In some cases this may be a partial ref, e.g. "LDA #>label".
            //      The symbol's value may not match the operand, in which case an adjustment
            //      is applied.
            //
            // We want to tag both.  So if "LDA $1000" becomes "LDA label-2", we want to
            // add a numeric reference to the code at $1000, and a symbolic reference to the
            // labe at $1002, that point back to the LDA instruction.  These are presented
            // slightly differently to the user.  For a symbolic reference with no adjustment,
            // we don't add the (redundant) numeric reference.
            //
            // In some cases the numeric reference will land in the middle of an instruction
            // or multi-byte data area and won't be visible.

            // Clear previous cross-reference data from project/platform symbols.  These
            // symbols don't have file offsets, so we can't store them in the main mXrefs
            // list.
            foreach (Symbol sym in SymbolTable) {
                if (sym is DefSymbol) {
                    (sym as DefSymbol).Xrefs.Clear();
                }
            }

            // Create a mapping from label (which must be unique) to file offset.  This
            // is different from UserLabels (which only has user-created labels, and is
            // sorted by offset) and SymbolTable (which has constants and platform symbols,
            // and uses the address as value rather than the offset).
            SortedList<string, int> labelList = new SortedList<string, int>(mFileData.Length,
                Asm65.Label.LABEL_COMPARER);
            for (int offset = 0; offset < mAnattribs.Length; offset++) {
                Anattrib attr = mAnattribs[offset];
                if (attr.Symbol != null) {
                    try {
                        labelList.Add(attr.Symbol.Label, offset);
                    } catch (ArgumentException ex) {
                        // Duplicate UserLabel entries are stripped when projects are loaded,
                        // but it might be possible to cause this by hiding/unhiding a
                        // label (e.g. using hints to place it in the middle of an instruction).
                        // Just ignore the duplicate.
                        Debug.WriteLine("Xref ignoring duplicate label '" + attr.Symbol.Label +
                            "': " + ex.Message);
                    }
                }
            }

            // Walk through the Anattrib array, adding xref entries to things referenced
            // by the entity at the current offset.
            for (int offset = 0; offset < mAnattribs.Length; ) {
                Anattrib attr = mAnattribs[offset];

                XrefSet.XrefType xrefType = XrefSet.XrefType.Unknown;
                if (attr.IsInstruction) {
                    OpDef op = CpuDef.GetOpDef(FileData[offset]);
                    if (op.IsBranch) {
                        xrefType = XrefSet.XrefType.BranchOperand;
                    } else {
                        xrefType = XrefSet.XrefType.InstrOperand;
                    }
                } else if (attr.IsData || attr.IsInlineData) {
                    xrefType = XrefSet.XrefType.DataOperand;
                }

                bool hasZeroOffsetSym = false;
                if (attr.DataDescriptor != null) {
                    FormatDescriptor dfd = attr.DataDescriptor;
                    if (dfd.FormatSubType == FormatDescriptor.SubType.Symbol) {
                        // For instructions with address operands that resolve in-file, grab
                        // the target offset.
                        int operandOffset = -1;
                        if (attr.IsInstructionStart) {
                            operandOffset = attr.OperandOffset;
                        }

                        // Is this a reference to a label?
                        if (labelList.TryGetValue(dfd.SymbolRef.Label, out int symOffset)) {
                            // Compute adjustment.
                            int adj = 0;
                            if (operandOffset >= 0) {
                                // We can compute (symOffset - operandOffset), but that gives us
                                // the offset adjustment, not the address adjustment.
                                adj = mAnattribs[symOffset].Address -
                                    mAnattribs[operandOffset].Address;
                            }

                            AddXref(symOffset, new XrefSet.Xref(offset, true, xrefType, adj));
                            if (adj == 0) {
                                hasZeroOffsetSym = true;
                            }
                        } else if (SymbolTable.TryGetValue(dfd.SymbolRef.Label, out Symbol sym)) {
                            // Is this a reference to a project/platform symbol?
                            if (sym.SymbolSource == Symbol.Source.Project ||
                                    sym.SymbolSource == Symbol.Source.Platform) {
                                DefSymbol defSym = sym as DefSymbol;
                                int adj = 0;
                                if (operandOffset >= 0) {
                                    adj = defSym.Value - operandOffset;
                                }
                                defSym.Xrefs.Add(new XrefSet.Xref(offset, true, xrefType, adj));
                            } else {
                                Debug.WriteLine("NOTE: not xrefing '" + sym.Label + "'");
                                Debug.Assert(false);    // not possible?
                            }
                        }
                    } else if (dfd.FormatSubType == FormatDescriptor.SubType.Address) {
                        // not expecting this format on an instruction operand
                        Debug.Assert(attr.IsData || attr.IsInlineData);
                        int operandOffset = RawData.GetWord(mFileData, offset,
                            dfd.Length, dfd.FormatType == FormatDescriptor.Type.NumericBE);
                        AddXref(operandOffset, new XrefSet.Xref(offset, false, xrefType, 0));
                    }

                    // Look for instruction offset references.  We skip this if we've already
                    // added a reference from a symbol with zero adjustment, since that would
                    // just leave a duplicate entry.  (The symbolic ref wins because we need
                    // it for the label localizer and possibly the label refactorer.)
                    if (!hasZeroOffsetSym && attr.IsInstructionStart && attr.OperandOffset >= 0) {
                        AddXref(attr.OperandOffset, new XrefSet.Xref(offset, false, xrefType, 0));
                    }
                }

                if (attr.IsDataStart) {
                    // There shouldn't be data items inside of other data items.
                    offset += attr.Length;
                } else {
                    // Advance by one, not attr.Length, so we don't miss embedded instructions.
                    offset++;
                }
            }
        }

        /// <summary>
        /// Adds an Xref entry to an XrefSet.  The XrefSet will be created if necessary.
        /// </summary>
        /// <param name="offset">File offset for which cross-references are being noted.</param>
        /// <param name="xref">Cross reference to add to the set.</param>
        private void AddXref(int offset, XrefSet.Xref xref) {
            if (!mXrefs.TryGetValue(offset, out XrefSet xset)) {
                xset = mXrefs[offset] = new XrefSet();
            }
            xset.Add(xref);
        }

        /// <summary>
        /// Returns the XrefSet for the specified offset.  May return null if the set is
        /// empty.
        /// </summary>
        public XrefSet GetXrefSet(int offset) {
            mXrefs.TryGetValue(offset, out XrefSet xset);
            return xset;        // will be null if not found
        }

        /// <summary>
        /// Generates the list of project/platform symbols that are being used.  Any
        /// DefSymbol with a non-empty Xrefs is included.  Previous contents are cleared.
        /// 
        /// The list is sorted primarily by value, secondarily by symbol name.
        /// 
        /// Call this after Xrefs are generated.
        /// </summary>
        private void GenerateActiveDefSymbolList() {
            ActiveDefSymbolList.Clear();

            foreach (Symbol sym in SymbolTable) {
                if (!(sym is DefSymbol)) {
                    continue;
                }
                DefSymbol defSym = sym as DefSymbol;
                if (defSym.Xrefs.Count == 0) {
                    continue;
                }
                ActiveDefSymbolList.Add(defSym);
            }

            // We could make symbol source the primary sort key, so that all platform
            // symbols appear before all project symbols.  Not sure if that's better.
            //
            // Could also skip this by replacing the earlier foreach with a walk through
            // SymbolTable.mSymbolsByValue, but I'm not sure that should be exposed.
            ActiveDefSymbolList.Sort(delegate (DefSymbol a, DefSymbol b) {
                if (a.Value < b.Value) {
                    return -1;
                } else if (a.Value > b.Value) {
                    return 1;
                }
                return Asm65.Label.LABEL_COMPARER.Compare(a.Label, b.Label);
            });
        }

        /// <summary>
        /// Checks some stuff.  Problems are handled with assertions, so this is only
        /// useful in debug builds.
        /// </summary>
        public void Validate() {
            // Confirm that we can walk through the file, stepping directly from the start
            // of one thing to the start of the next.
            int offset = 0;
            while (offset < mFileData.Length) {
                Anattrib attr = mAnattribs[offset];
                Debug.Assert(attr.IsStart);
                Debug.Assert(attr.Length != 0);
                offset += attr.Length;

                // Sometimes embedded instructions continue past the "outer" instruction,
                // usually because we're misinterpreting the code.  We need to deal with
                // that here.
                int extraInstrBytes = 0;
                while (offset < mFileData.Length && mAnattribs[offset].IsInstruction &&
                        !mAnattribs[offset].IsInstructionStart) {
                    extraInstrBytes++;
                    offset++;
                }
                //if (extraInstrBytes > 0) { Debug.WriteLine("EIB=" + extraInstrBytes); }
                // Max instruction len is 4, so the stray part must be shorter.
                Debug.Assert(extraInstrBytes < 4);
            }
            Debug.Assert(offset == mFileData.Length);

            // Confirm that all bytes are tagged as code, data, or inline data.  The Asserts
            // in Anattrib should confirm that nothing is tagged as more than one thing.
            for (offset = 0; offset < mAnattribs.Length; offset++) {
                Anattrib attr = mAnattribs[offset];
                Debug.Assert(attr.IsInstruction || attr.IsInlineData || attr.IsData);
            }

            // Confirm that there are no Default format entries in OperandFormats.
            foreach (KeyValuePair<int, FormatDescriptor> kvp in OperandFormats) {
                Debug.Assert(kvp.Value.FormatType != FormatDescriptor.Type.Default);
                Debug.Assert(kvp.Value.FormatType != FormatDescriptor.Type.REMOVE);
            }
        }

        /// <summary>
        /// Generates a ChangeSet that merges the FormatDescriptors in the new list into
        /// OperandFormats.
        /// 
        /// All existing descriptors that overlap with new descriptors will be removed.
        /// In cases where old and new descriptors have the same starting offset, this
        /// will be handled with a single change object.
        /// 
        /// If old and new descriptors are identical, no change object will be generated.
        /// It's possible for this to return an empty change set.
        /// </summary>
        /// <param name="newList">List of new format descriptors.</param>
        /// <returns>Change set.</returns>
        public ChangeSet GenerateFormatMergeSet(SortedList<int, FormatDescriptor> newList) {
            Debug.WriteLine("Generating format merge set...");
            ChangeSet cs = new ChangeSet(newList.Count * 2);

            // The Keys and Values properties are documented to return the internal data
            // structure, not make a copy, so this will be fast.
            IList<int> mainKeys = OperandFormats.Keys;
            IList<FormatDescriptor> mainValues = OperandFormats.Values;
            IList<int> newKeys = newList.Keys;
            IList<FormatDescriptor> newValues = newList.Values;

            // The basic idea is to walk through the new list, checking each entry for
            // conflicts with the main list.  If there's no conflict, we create a change
            // object for the new item.  If there is a conflict, we resolve it appropriately.
            //
            // The check on the main list is very fast because both lists are in sorted
            // order, so we can just walk the main list forward.  If a main-list entry
            // conflicts, we create a removal object, and advance the main index.
            int mainIndex = 0;
            int newIndex = 0;
            while (newIndex < newKeys.Count) {
                int newOffset = newKeys[newIndex];
                int newLength = newValues[newIndex].Length;
                if (mainIndex >= mainKeys.Count) {
                    // We've run off the end of the main list.  Just add the new item.
                    UndoableChange uc = UndoableChange.CreateActualOperandFormatChange(
                        newOffset, null, newValues[newIndex]);
                    cs.AddNonNull(uc);
                    newIndex++;
                    continue;
                }

                // Check for overlap by computing the intersection.  Start and end form two
                // points; the intersection is the largest of the start points and the
                // smallest of the end points.  If the result of the computation puts end before
                // start, there's no overlap.
                int mainOffset = mainKeys[mainIndex];
                int mainLength = mainValues[mainIndex].Length;
                Debug.Assert(newLength > 0 && mainLength > 0);
                int interStart = Math.Max(mainOffset, newOffset);
                int interEnd = Math.Min(mainOffset + mainLength, newOffset + newLength);
                // exclusive end point, so interEnd == interStart means no overlap
                if (interEnd > interStart) {
                    Debug.WriteLine("Found overlap: main(+" + mainOffset.ToString("x6") +
                        "," + mainLength + ") : new(+" + newOffset.ToString("x6") +
                        "," + newLength + ")");

                    // See if the initial offsets are identical.  If so, put the add and
                    // remove into a single change.  This isn't strictly necessary, but it's
                    // slightly more efficient.
                    if (mainOffset == newOffset) {
                        // Check to see if the descriptors are identical.  If so, ignore this.
                        if (mainValues[mainIndex] == newValues[newIndex]) {
                            Debug.WriteLine(" --> no-op change " + newValues[newIndex]);
                        } else {
                            Debug.WriteLine(" --> replace change " + newValues[newIndex]);
                            UndoableChange uc = UndoableChange.CreateActualOperandFormatChange(
                                newOffset, mainValues[mainIndex], newValues[newIndex]);
                            cs.AddNonNull(uc);
                        }
                    } else {
                        // Remove the old entry, add the new entry.
                        Debug.WriteLine(" --> remove/add change " + newValues[newIndex]);
                        UndoableChange ruc = UndoableChange.CreateActualOperandFormatChange(
                            mainOffset, mainValues[mainIndex], null);
                        UndoableChange auc = UndoableChange.CreateActualOperandFormatChange(
                            newOffset, null, newValues[newIndex]);
                        cs.AddNonNull(ruc);
                        cs.AddNonNull(auc);
                    }
                    newIndex++;

                    // Remove all other main-list entries that overlap with this one.
                    while (++mainIndex < mainKeys.Count) {
                        mainOffset = mainKeys[mainIndex];
                        mainLength = mainValues[mainIndex].Length;
                        interStart = Math.Max(mainOffset, newOffset);
                        interEnd = Math.Min(mainOffset + mainLength, newOffset + newLength);
                        // exclusive end point, so interEnd == interStart means no overlap
                        if (interEnd <= interStart) {
                            break;
                        }
                        Debug.WriteLine(" also remove +" + mainOffset.ToString("x6") +
                            mainValues[mainIndex]);
                        UndoableChange uc = UndoableChange.CreateActualOperandFormatChange(
                            mainOffset, mainValues[mainIndex], null);
                        cs.AddNonNull(uc);
                    }
                } else {
                    // No overlap.  If the main entry is earlier, we can cross it off the list
                    // and advance to the next one.  Otherwise, we add the change and advance
                    // that list.
                    if (mainOffset < newOffset) {
                        mainIndex++;
                    } else {
                        Debug.WriteLine("Add non-overlap " + newOffset.ToString("x6") +
                            newValues[newIndex]);
                        UndoableChange uc = UndoableChange.CreateActualOperandFormatChange(
                            newOffset, null, newValues[newIndex]);
                        cs.AddNonNull(uc);
                        newIndex++;
                    }
                }
            }

            // Trim away excess capacity, since this will probably be sitting in an undo
            // list for a long time.
            cs.TrimExcess();
            Debug.WriteLine("Total " + cs.Count + " changes");
            return cs;
        }

        /// <summary>
        /// Returns the analyzer attributes for the specified byte offset.
        /// 
        /// Bear in mind that Anattrib is a struct, and thus the return value is a copy.
        /// </summary>
        public Anattrib GetAnattrib(int offset) {
            return mAnattribs[offset];
        }

        /// <summary>
        /// Returns true if the offset has a long comment or note.  Used for determining how to
        /// split up a data area.  Currently not returning true for an end-of-line comment.
        /// </summary>
        /// <param name="offset">Offset of interest.</param>
        /// <returns>True if a comment or note was found.</returns>
        public bool HasCommentOrNote(int offset) {
            return (LongComments.ContainsKey(offset) ||
                    Notes.ContainsKey(offset));
        }

        /// <summary>
        /// True if an "undo" operation is available.
        /// </summary>
        public bool CanUndo { get { return mUndoTop > 0; } }

        /// <summary>
        /// True if a "redo" operation is available.
        /// </summary>
        public bool CanRedo { get { return mUndoTop < mUndoList.Count; } }

        /// <summary>
        /// True if something has changed since the last time the file was saved.
        /// </summary>
        public bool IsDirty { get { return mUndoTop != mUndoSaveIndex; } }

        /// <summary>
        /// Sets the save index equal to the undo position.  Do this after the file has
        /// been successfully saved.
        /// </summary>
        public void ResetDirtyFlag() {
            mUndoSaveIndex = mUndoTop;
        }

        /// <summary>
        /// Returns the next undo operation, and moves the pointer to the previous item.
        /// </summary>
        public ChangeSet PopUndoSet() {
            if (!CanUndo) {
                throw new Exception("Can't undo");
            }
            Debug.WriteLine("PopUndoSet: returning entry " + (mUndoTop - 1) + ": " +
                mUndoList[mUndoTop - 1]);
            return mUndoList[--mUndoTop];
        }

        /// <summary>
        /// Returns the next redo operation, and moves the pointer to the next item.
        /// </summary>
        /// <returns></returns>
        public ChangeSet PopRedoSet() {
            if (!CanRedo) {
                throw new Exception("Can't redo");
            }
            Debug.WriteLine("PopRedoSet: returning entry " + mUndoTop + ": " +
                mUndoList[mUndoTop]);
            return mUndoList[mUndoTop++];
        }

        /// <summary>
        /// Adds a change set to the undo list.  All redo operations above it on the
        /// stack are removed.
        /// 
        /// We currently allow empty sets.
        /// </summary>
        /// <param name="changeSet">Set to push.</param>
        public void PushChangeSet(ChangeSet changeSet) {
            Debug.WriteLine("PushChangeSet: adding " + changeSet);

            // Remove all of the "redo" entries from the current position to the end.
            if (mUndoTop < mUndoList.Count) {
                Debug.WriteLine("PushChangeSet: removing " + (mUndoList.Count - mUndoTop) +
                    " entries");
                mUndoList.RemoveRange(mUndoTop, mUndoList.Count - mUndoTop);
            }

            mUndoList.Add(changeSet);
            mUndoTop = mUndoList.Count;
        }

        public string DebugGetUndoRedoHistory() {
            StringBuilder sb = new StringBuilder();
            sb.Append("Bracketed change will be overwritten by next action\r\n\r\n");

            for (int i = 0; i < mUndoList.Count; i++) {
                ChangeSet cs = mUndoList[i];

                char lbr, rbr;
                if (i == mUndoTop) {
                    lbr = '[';
                    rbr = ']';
                } else {
                    lbr = rbr = ' ';
                }
                sb.AppendFormat("{0}{3,3:D}{1}{2}: {4} change{5}\r\n",
                    lbr, rbr, i == mUndoSaveIndex ? "*" : " ",
                    i, cs.Count, cs.Count == 1 ? "" : "s");

                for (int j = 0; j < cs.Count; j++) {
                    UndoableChange uc = cs[j];
                    sb.AppendFormat("       type={0} offset=+{1} reReq={2}\r\n",
                        uc.Type, uc.HasOffset ? uc.Offset.ToString("x6") : "N/A",
                        uc.ReanalysisRequired);
                }
            }
            if (mUndoTop == mUndoList.Count) {
                sb.AppendFormat("[ - ]{0}\r\n", mUndoTop == mUndoSaveIndex ? "*" : " ");
            }

            return sb.ToString();
        }

        /// <summary>
        /// Applies the changes to the project, and updates the display.
        /// </summary>
        /// <param name="cs">Set of changes to apply.</param>
        /// <param name="backward">If set, undo the changes instead.</param>
        /// <param name="affectedOffsets">List of offsets affected by change.</param>
        /// <returns>An indication of the level of reanalysis required.  If this returns None,
        ///   the list of offsets to update will be in affectedOffsets.</returns>
        public UndoableChange.ReanalysisScope ApplyChanges(ChangeSet cs, bool backward,
                out RangeSet affectedOffsets) {
            affectedOffsets = new RangeSet();

            UndoableChange.ReanalysisScope needReanalysis = UndoableChange.ReanalysisScope.None;

            // TODO(maybe): if changes overlap, we need to apply them in reverse order when
            //   "backward" is set.  This requires a reverse-order enumerator from
            //   ChangeSet.  Not currently needed.
            foreach (UndoableChange uc in cs) {
                object oldValue, newValue;

                // Unpack change, flipping old/new for undo.
                if (!backward) {
                    oldValue = uc.OldValue;
                    newValue = uc.NewValue;
                } else {
                    oldValue = uc.NewValue;
                    newValue = uc.OldValue;
                }
                int offset = uc.Offset;

                switch (uc.Type) {
                    case UndoableChange.ChangeType.Dummy:
                        //if (uc.ReanalysisRequired == UndoableChange.ReanalysisFlags.None) {
                        //    affectedOffsets.AddRange(0, FileData.Length - 1);
                        //}
                        break;
                    case UndoableChange.ChangeType.SetAddress: {
                            AddressMap addrMap = AddrMap;
                            if (addrMap.Get(offset) != (int)oldValue) {
                                Debug.WriteLine("GLITCH: old address value mismatch (" +
                                    addrMap.Get(offset) + " vs " + (int)oldValue + ")");
                                Debug.Assert(false);
                            }
                            addrMap.Set(offset, (int)newValue);

                            Debug.WriteLine("Map offset +" + offset.ToString("x6") + " to $" +
                                ((int)newValue).ToString("x6"));

                            // ignore affectedOffsets
                            Debug.Assert(uc.ReanalysisRequired ==
                                UndoableChange.ReanalysisScope.CodeAndData);
                        }
                        break;
                    case UndoableChange.ChangeType.SetTypeHint: {
                            // Always requires full code+data re-analysis.
                            ApplyTypeHints((TypedRangeSet)oldValue, (TypedRangeSet)newValue);
                            // ignore affectedOffsets
                            Debug.Assert(uc.ReanalysisRequired ==
                                UndoableChange.ReanalysisScope.CodeAndData);
                        }
                        break;
                    case UndoableChange.ChangeType.SetStatusFlagOverride: {
                            if (StatusFlagOverrides[offset] != (StatusFlags)oldValue) {
                                Debug.WriteLine("GLITCH: old status flag mismatch (" +
                                    StatusFlagOverrides[offset] + " vs " +
                                    (StatusFlags)oldValue + ")");
                                Debug.Assert(false);
                            }
                            StatusFlagOverrides[offset] = (StatusFlags)newValue;
                            // ignore affectedOffsets
                            Debug.Assert(uc.ReanalysisRequired ==
                                UndoableChange.ReanalysisScope.CodeAndData);
                        }
                        break;
                    case UndoableChange.ChangeType.SetLabel: {
                            // NOTE: this is about managing changes to UserLabels.  Adding
                            // or removing a user-defined label requires a full reanalysis,
                            // even if there was already an auto-generated label present,
                            // so we don't need to undo/redo Anattribs for anything except
                            // for renaming a user-defined label.
                            UserLabels.TryGetValue(offset, out Symbol oldSym);
                            if (oldSym != (Symbol) oldValue) {
                                Debug.WriteLine("GLITCH: old label value mismatch ('" +
                                    oldSym + "' vs '" + oldValue + "')");
                                Debug.Assert(false);
                            }

                            if (newValue == null) {
                                // We're removing a user label.
                                UserLabels.Remove(offset);
                                SymbolTable.Remove((Symbol)oldValue);   // unnecessary?
                                Debug.Assert(uc.ReanalysisRequired ==
                                    UndoableChange.ReanalysisScope.DataOnly);
                            } else {
                                // We're adding or renaming a user label.
                                //
                                // We should not be changing a label to the same value as an
                                // existing label -- the dialog should have prevented it.
                                // This is important because, if we edit a label to match an
                                // auto-generated label, we'll have a duplicate label unless we
                                // do a full code+data reanalysis.  If we're okay with reanalyzing
                                // on user-label renames, we can allow such conflicts.
                                if (oldValue != null) {
                                    SymbolTable.Remove((Symbol)oldValue);
                                }
                                UserLabels[offset] = (Symbol)newValue;
                                //SymbolTable[((Symbol)newValue).Label] = (Symbol)newValue;
                                SymbolTable.Add((Symbol)newValue);
                                Debug.Assert(oldSym != null || uc.ReanalysisRequired ==
                                    UndoableChange.ReanalysisScope.DataOnly);
                            }

                            if (uc.ReanalysisRequired == UndoableChange.ReanalysisScope.None) {
                                // Shouldn't really be "changing" from null to null, but
                                // it's legal, so don't blow up if it happens.
                                // (The assert on SymbolSource is older -- we now only care about
                                // what's in UserLabels, which are always Source=User.)
                                Debug.Assert((oldValue == null && newValue == null) ||
                                    (((Symbol)oldValue).SymbolSource == Symbol.Source.User &&
                                     ((Symbol)newValue).SymbolSource == Symbol.Source.User));
                                // Not doing a full refresh, so keep this up to date.
                                mAnattribs[offset].Symbol = (Symbol)newValue;

                                if (oldValue != null) {
                                    // Update everything in Anattribs and OperandFormats that
                                    // referenced the old symbol.
                                    RefactorLabel(offset, ((Symbol)oldValue).Label);
                                }

                                affectedOffsets.Add(offset);

                                // Use the cross-reference table to identify the offsets that
                                // we need to update.
                                if (mXrefs.TryGetValue(offset, out XrefSet xrefs)) {
                                    foreach (XrefSet.Xref xr in xrefs) {
                                        // This isn't quite right -- in theory we should be adding
                                        // all offsets that are part of the instruction, so that
                                        // affectedOffsets can hold a contiguous range instead of
                                        // a collection of opcode offsets.  In practice, for a
                                        // label change, it shouldn't matter.
                                        affectedOffsets.Add(xr.Offset);
                                    }
                                }
                            } else {
                                // We're not calling RefactorLabel() here because we should
                                // only be doing the reanalysis if we're adding or removing
                                // the label, not renaming it.  If that changes, we'll need
                                // to do the refactor here, though we can skip Anattribs work.
                                Debug.Assert(oldValue == null || newValue == null);
                            }
                        }
                        break;
                    case UndoableChange.ChangeType.SetOperandFormat: {
                            // Note this is used for data/inline-data as well as instructions.
                            OperandFormats.TryGetValue(offset, out FormatDescriptor current);
                            if (current != (FormatDescriptor)oldValue) {
                                Debug.WriteLine("GLITCH: old operand format mismatch (" +
                                    current + " vs " + oldValue + ")");
                                Debug.Assert(false);
                            }
                            if (newValue == null) {
                                OperandFormats.Remove(offset);
                                mAnattribs[offset].DataDescriptor = null;
                            } else {
                                OperandFormats[offset] = mAnattribs[offset].DataDescriptor =
                                    (FormatDescriptor)newValue;
                            }
                            if (uc.ReanalysisRequired == UndoableChange.ReanalysisScope.None) {
                                // Add every offset in the range.  The length might be changing
                                // (e.g. an offset with a single byte is now the start of a
                                // 10-byte string), so touch everything that was affected by
                                // the old descriptor or is affected by the new descriptor.
                                // [This may no longer be necessary -- size changes now
                                // require reanalysis.]
                                int afctLen = 1;
                                if (oldValue != null) {
                                    afctLen =
                                        Math.Max(afctLen, ((FormatDescriptor)oldValue).Length);
                                }
                                if (newValue != null) {
                                    afctLen =
                                        Math.Max(afctLen, ((FormatDescriptor)newValue).Length);
                                }

                                for (int i = offset; i < offset + afctLen; i++) {
                                    affectedOffsets.Add(i);
                                }
                            }
                        }
                        break;
                    case UndoableChange.ChangeType.SetComment: {
                            if (!Comments[offset].Equals(oldValue)) {
                                Debug.WriteLine("GLITCH: old comment value mismatch ('" +
                                    Comments[offset] + "' vs '" + oldValue + "')");
                                Debug.Assert(false);
                            }
                            Comments[offset] = (string)newValue;

                            // Only affects this offset.
                            affectedOffsets.Add(offset);
                        }
                        break;
                    case UndoableChange.ChangeType.SetLongComment: {
                            LongComments.TryGetValue(offset, out MultiLineComment current);
                            if (current != (MultiLineComment)oldValue) {
                                Debug.WriteLine("GLITCH: old long comment value mismatch ('" +
                                    current + "' vs '" + oldValue + "')");
                                Debug.Assert(false);
                            }
                            if (newValue == null) {
                                LongComments.Remove(offset);
                            } else {
                                LongComments[offset] = (MultiLineComment)newValue;
                            }

                            // Only affects this offset.
                            affectedOffsets.Add(offset);
                        }
                        break;
                    case UndoableChange.ChangeType.SetNote: {
                            Notes.TryGetValue(offset, out MultiLineComment current);
                            if (current != (MultiLineComment)oldValue) {
                                Debug.WriteLine("GLITCH: old note value mismatch ('" +
                                    current + "' vs '" + oldValue + "')");
                                Debug.Assert(false);
                            }
                            if (newValue == null) {
                                Notes.Remove(offset);
                            } else {
                                Notes[offset] = (MultiLineComment)newValue;
                            }

                            // Only affects this offset.
                            affectedOffsets.Add(offset);
                        }
                        break;
                    case UndoableChange.ChangeType.SetProjectProperties: {
                            bool needExternalFileReload = !CommonUtil.Container.StringListEquals(
                                ((ProjectProperties)oldValue).PlatformSymbolFileIdentifiers,
                                ((ProjectProperties)newValue).PlatformSymbolFileIdentifiers,
                                null /*StringComparer.InvariantCulture*/);
                            needExternalFileReload |= !CommonUtil.Container.StringListEquals(
                                ((ProjectProperties)oldValue).ExtensionScriptFileIdentifiers,
                                ((ProjectProperties)newValue).ExtensionScriptFileIdentifiers,
                                null);

                            // ProjectProperties are mutable, so create a new object that's
                            // a clone of the one that will live in the undo buffer.
                            ProjectProps = new ProjectProperties((ProjectProperties)newValue);

                            // Most of the properties are simply used during the reanalysis
                            // process.  This must be set explicitly.  NOTE: replacing this
                            // could cause cached data (such as Formatter strings) to be
                            // discarded, so ideally we wouldn't do this unless we know the
                            // CPU definition has changed (or we know that GetBestMatch is
                            // memoizing results and will return the same object).
                            Debug.WriteLine("Replacing CPU def object");
                            UpdateCpuDef();

                            if (needExternalFileReload) {
                                LoadExternalFiles();
                            }
                        }
                        break;
                    default:
                        break;
                }
                needReanalysis |= uc.ReanalysisRequired;
            }

            return needReanalysis;
        }

        /// <summary>
        /// Updates all symbolic references to the old label.
        /// </summary>
        /// <param name="labelOffset">Offset with the just-renamed label.</param>
        /// <param name="oldLabel">Previous value.</param>
        private void RefactorLabel(int labelOffset, string oldLabel) {
            if (!mXrefs.TryGetValue(labelOffset, out XrefSet xrefs)) {
                // This can happen if you add a label in the middle of nowhere and rename it.
                Debug.WriteLine("RefactorLabel: no references to " + oldLabel);
                return;
            }

            string newLabel = mAnattribs[labelOffset].Symbol.Label;

            //
            // Update format descriptors in Anattribs.
            //
            foreach (XrefSet.Xref xr in xrefs) {
                FormatDescriptor dfd = mAnattribs[xr.Offset].DataDescriptor;
                if (dfd == null) {
                    // Should be a data target reference here?  Where'd the xref come from?
                    Debug.Assert(false);
                    continue;
                }
                if (!dfd.HasSymbol) {
                    // The auto-gen stuff would have created a symbol, but the user can
                    // override that and display as e.g. hex.
                    continue;
                }
                if (!Label.LABEL_COMPARER.Equals(oldLabel, dfd.SymbolRef.Label)) {
                    // This can happen if the xref is based on the operand offset,
                    // but the user picked a different symbol.  The xref generator
                    // creates entries for both target offsets, but only one will
                    // have a matching label.
                    continue;
                }

                mAnattribs[xr.Offset].DataDescriptor = FormatDescriptor.Create(
                    dfd.Length, new WeakSymbolRef(newLabel, dfd.SymbolRef.ValuePart),
                    dfd.FormatType == FormatDescriptor.Type.NumericBE);
            }

            //
            // Update value in OperandFormats.
            //
            foreach (XrefSet.Xref xr in xrefs) {
                if (!OperandFormats.TryGetValue(xr.Offset, out FormatDescriptor dfd)) {
                    // Probably an auto-generated symbol ref, so no entry in OperandFormats.
                    continue;
                }
                if (!dfd.HasSymbol) {
                    continue;
                }
                if (!Label.LABEL_COMPARER.Equals(oldLabel, dfd.SymbolRef.Label)) {
                    continue;
                }

                Debug.WriteLine("Replacing symbol at +" + xr.Offset.ToString("x6") +
                    " with " + newLabel);
                OperandFormats[xr.Offset] = FormatDescriptor.Create(
                    dfd.Length, new WeakSymbolRef(newLabel, dfd.SymbolRef.ValuePart),
                    dfd.FormatType == FormatDescriptor.Type.NumericBE);
            }
        }


        /// <summary>
        /// Applies the values in the set to the project hints.
        /// </summary>
        /// <param name="oldSet">Previous values; must match current contents.</param>
        /// <param name="newSet">Values to apply.</param>
        private void ApplyTypeHints(TypedRangeSet oldSet, TypedRangeSet newSet) {
            CodeAnalysis.TypeHint[] hints = TypeHints;
            foreach (TypedRangeSet.Tuple tuple in newSet) {
                CodeAnalysis.TypeHint curType = hints[tuple.Value];
                if (!oldSet.GetType(tuple.Value, out int oldType) || oldType != (int)curType) {
                    Debug.WriteLine("Type mismatch at " + tuple.Value);
                    Debug.Assert(false);
                }

                //Debug.WriteLine("Set +" + tuple.Value.ToString("x6") + " to " +
                //    (CodeAnalysis.TypeHint)tuple.Type + " (was " +
                //    curType + ")");

                hints[tuple.Value] = (CodeAnalysis.TypeHint)tuple.Type;
            }
        }

        /// <summary>
        /// Finds a label by name.  SymbolTable must be populated.
        /// </summary>
        /// <param name="name">Label to find.</param>
        /// <returns>File offset associated with label, or -1 if not found.</returns>
        public int FindLabelByName(string name) {
            // We're interested in user labels and auto-generated labels.  Do a lookup in
            // SymbolTable to find the symbol, then if it's user or auto, we do a second
            // search to find the file offset it's associated with.  The second search
            // requires a linear walk through anattribs; if we do this often we'll want to
            // maintain a symbol-to-offset structure.
            //
            // This will not find "hidden" labels, i.e. labels that are in the middle of an
            // instruction or multi-byte data area, because those are removed from SymbolTable.
            if (!SymbolTable.TryGetValue(name, out Symbol sym)) {
                return -1;
            }
            if (sym.SymbolSource != Symbol.Source.Auto && sym.SymbolSource != Symbol.Source.User) {
                return -1;
            }
            for (int i = 0; i < mAnattribs.Length; i++) {
                if (mAnattribs[i].Symbol == sym) {
                    return i;
                }
            }
            Debug.WriteLine("NOTE: symbol '" + name + "' exists, but wasn't found in labels");
            return -1;
        }

        /// <summary>
        /// For debugging purposes, get some information about the currently loaded
        /// extension scripts.
        /// </summary>
        public string DebugGetLoadedScriptInfo() {
            return mScriptManager.DebugGetLoadedScriptInfo();
        }
    }
}