#/*
# * Copyright (c) 2019,2020 Xilinx Inc. All rights reserved.
# *
# * Author:
# *       Bruce Ashfield <bruce.ashfield@xilinx.com>
# *
# * SPDX-License-Identifier: BSD-3-Clause
# */

import sys
import os
import re
import shutil
from pathlib import Path
from io import StringIO
import contextlib
from importlib.machinery import SourceFileLoader
import tempfile
from collections import OrderedDict
import json

from lopper.fmt import LopperFmt
from lopper.fdt import LopperFDT

from lopper.tree import LopperNode, LopperTree, LopperTreePrinter, LopperProp
import lopper.tree

import lopper.log

lopper_directory = os.path.dirname(os.path.realpath(__file__))

try:
    from lopper.yaml import *
    yaml_support = True
except Exception as e:
    lopper.log._warning( f"cant load yaml, disabling support: {e}" )
    yaml_support = False

# Default processing type
Lopper = LopperFDT
lopper.tree.Lopper = Lopper

@contextlib.contextmanager
def stdoutIO(stdout=None):
    old = sys.stdout
    if stdout is None:
        stdout = StringIO()
        sys.stdout = stdout
        yield stdout
        sys.stdout = old

def resolve_path_with_drive_preservation(path_obj):
    """
    Resolves a path to absolute form while preserving drive substitution on Windows.

    On Windows, this function checks if the file exists before resolving to preserve
    substituted drives. On other systems or when the file doesn't exist, it uses
    the standard resolve() method.

    Args:
        path_obj (Path): Path object to resolve

    Returns:
        Path: Resolved absolute path
    """
    if sys.platform.startswith("win") and path_obj.exists():
        # For Windows, if the file exists, convert to absolute path but preserve drive substitution
        return path_obj.absolute()
    else:
        # Original behavior for other systems or when file doesn't exist
        if sys.version_info.minor < 6:
            return path_obj.resolve()
        else:
            return path_obj.resolve(True)

def lopper_type(cls):
    global Lopper
    Lopper = cls
    lopper.tree.Lopper = cls

class LopperAssist:
    """Internal class to contain the details of a lopper assist

    """
    def __init__(self, lop_file, module = "", properties_dict = {}):
        self.module = module
        self.file = lop_file
        # holds specific key,value properties
        self.properties = properties_dict


def is_overlay_file(filepath):
    """Check if a DTS file is a true device-tree overlay.

    Returns True only when the file contains an &label { } block AND
    one of the following overlay markers is present:
      - a /plugin/; directive (dtc's overlay marker), or
      - a .dtso extension (Linux overlay-source convention).

    A plain .dtsi fragment that uses &label { } without /plugin/; is
    NOT an overlay: it is an include fragment that dtc resolves
    natively when concatenated with the base tree, and treating it
    as an overlay would park its contents under /__lopper-overlays__/
    instead of merging them into the base SDT.

    Args:
        filepath: Path to the DTS/DTSI/DTSO file to check

    Returns:
        bool: True if file is a true overlay, False otherwise
    """
    try:
        with open(filepath, 'r') as f:
            content = f.read()
    except Exception:
        return False

    if not re.search(r'&\w+\s*\{', content):
        return False

    if filepath.endswith('.dtso'):
        return True

    return bool(re.search(r'/plugin/\s*;', content))


def _extract_overlay_targets_regex(filepath):
    """Extract overlay targets using a brace-counting parser (fallback method).

    Used when compiled tree analysis is not possible (e.g., during early
    setup before full SDT is loaded, or when dtc is unavailable).

    Handles arbitrarily nested child nodes inside &label { } blocks by
    counting braces rather than using a fixed-depth regex.

    Args:
        filepath: Path to the overlay DTS/DTSI file

    Returns:
        dict: Mapping of label names to a dict with keys:
              'props'    - list of direct property names on the overlay target
              'children' - list of direct child node names added by the overlay
              Example: {'mmi_dc': {'props': ['status', 'clocks'],
                                   'children': ['ports']}}
    """
    targets = {}

    try:
        with open(filepath, 'r') as f:
            content = f.read()

        # Find the start of each &label { block using a simple scan
        label_re = re.compile(r'&(\w+)\s*\{', re.DOTALL)
        prop_re = re.compile(r'^([a-zA-Z][a-zA-Z0-9,._+#-]*)\s*[=;]')
        child_re = re.compile(r'^([a-zA-Z][a-zA-Z0-9,._+#-]*)\s*\{')

        for m in label_re.finditer(content):
            label = m.group(1)
            # Walk forward counting braces to find the matching closing brace
            pos = m.end()   # just past the opening '{'
            depth = 1
            block_start = pos
            block_end = pos
            while pos < len(content) and depth > 0:
                ch = content[pos]
                if ch == '{':
                    depth += 1
                elif ch == '}':
                    depth -= 1
                    if depth == 0:
                        block_end = pos
                pos += 1

            block = content[block_start:block_end]

            # Parse direct properties and direct child nodes at depth=1
            # We need to skip over nested blocks when looking for properties
            props = []
            children = []
            inner_pos = 0
            inner_depth = 0
            line_buf = []
            while inner_pos < len(block):
                ch = block[inner_pos]
                if ch == '{':
                    if inner_depth == 0:
                        # The text in line_buf is the child node name line
                        child_line = ''.join(line_buf).strip()
                        child_m = child_re.match(child_line)
                        if child_m:
                            children.append(child_m.group(1))
                        line_buf = []
                    inner_depth += 1
                elif ch == '}':
                    inner_depth -= 1
                    line_buf = []
                elif ch == '\n':
                    if inner_depth == 0:
                        line = ''.join(line_buf).strip()
                        prop_m = prop_re.match(line)
                        if prop_m:
                            props.append(prop_m.group(1))
                    line_buf = []
                else:
                    if inner_depth == 0:
                        line_buf.append(ch)
                inner_pos += 1

            if label in targets:
                targets[label]['props'].extend(props)
                targets[label]['children'].extend(children)
            else:
                targets[label] = {'props': props, 'children': children}

    except Exception as e:
        lopper.log._warning(f"Failed to extract overlay targets from {filepath}: {e}")

    return targets


def _unwrap_overlay_tree(ov_tree, base_tree):
    """Unwrap a dtc plugin-compiled overlay tree into clean LopperNode objects.

    dtc compiles &label{} overlays into:
        /fragment@N  { target = <&label>; __overlay__ { props; children; }; }
        /__fixups__  { label = "/fragment@N:target:0"; other = "..."; }

    This function reverses that structure:
    - Reads __fixups__ to map each fragment@N to its target label
    - Looks up that label in base_tree to find the real abs_path
    - Copies __overlay__ props/children onto a clean node at that path
    - Resolves 0xffffffff phandle placeholders using __fixups__ byte-offsets

    Returns a list of LopperNode objects ready for storage in overlay_subtrees.
    Nodes whose target label cannot be resolved against base_tree are skipped
    with a warning.
    """
    import copy

    # --- step 1: read __fixups__ ---
    # Map fragment path ("/fragment@0") -> target label ("mmi_dc")
    # Map label -> list of fixup records ("node_path:prop_name:byte_offset")
    fragment_to_label = {}   # "/fragment@0" -> "mmi_dc"
    label_to_fixups   = {}   # "mmi_dc" -> ["/fragment@0/__overlay__:clocks:4", ...]

    fixups_nodes = ov_tree.nodes('/__fixups__')
    fixups_node  = fixups_nodes[0] if fixups_nodes else None

    if fixups_node:
        for prop in fixups_node.__props__.values():
            label = prop.name
            refs  = prop.value if isinstance(prop.value, list) else [prop.value]
            for ref in refs:
                if not isinstance(ref, str):
                    continue
                frag_path = ref.split(':')[0]   # "/fragment@0" or "/fragment@0/__overlay__/child"
                # Target entry: path is exactly /fragment@N (no sub-path)
                if frag_path.count('/') == 1 and ':target:' in ref:
                    fragment_to_label[frag_path] = label
                else:
                    label_to_fixups.setdefault(label, []).append(ref)

    # --- step 2: resolve label -> real phandle from base_tree ---
    label_to_phandle = {}
    for label in label_to_fixups:
        nodes = base_tree.lnodes(label, exact=True)
        if nodes and nodes[0].phandle and nodes[0].phandle > 0:
            label_to_phandle[label] = nodes[0].phandle

    # --- step 3: walk fragment@N nodes, build clean result nodes ---
    result_nodes = []

    for node in ov_tree:
        if not node.name.startswith('fragment@'):
            continue

        frag_path = node.abs_path    # e.g. "/fragment@0"
        label = fragment_to_label.get(frag_path)
        if label is None:
            continue

        # Find target abs_path in base_tree
        target_nodes = base_tree.lnodes(label, exact=True)
        if not target_nodes:
            lopper.log._warning(f"overlay: label '{label}' not found in base tree, skipping")
            continue
        target_abs_path = target_nodes[0].abs_path

        # Find __overlay__ child
        overlay_child = None
        for child in node.child_nodes.values():
            if child.name == '__overlay__':
                overlay_child = child
                break
        if overlay_child is None:
            continue

        # Build a clean node at the target path; store the label so callers
        # can emit &label fragments without re-querying the base tree.
        clean_node = LopperNode(name=target_nodes[0].name)
        clean_node.__dict__['abs_path'] = target_abs_path
        clean_node.label = label

        # Resolve 0xffffffff phandle placeholders in-place across
        # overlay_child and all its children (ov_tree is temporary)
        ov_node_prefix = frag_path + '/__overlay__'
        for fix_label, fix_refs in label_to_fixups.items():
            ph = label_to_phandle.get(fix_label)
            if not ph: continue
            for ref in fix_refs:
                try:
                    rn, rp, bo = ref.rsplit(':', 2)
                    if not rn.startswith(ov_node_prefix): continue
                    prop = ov_tree[rn].__props__.get(rp)
                    if not prop: continue
                    val = list(prop.__dict__.get('value', []))
                    idx = int(bo) // 4
                    if idx < len(val) and val[idx] == 4294967295:
                        val[idx] = ph
                        prop.__dict__['value'] = val
                except Exception:
                    pass

        # Copy resolved properties to clean_node
        for prop in overlay_child.__props__.values():
            new_prop = copy.deepcopy(prop)
            new_prop.node = clean_node
            clean_node.__props__[prop.name] = new_prop

        # Recursively copy child nodes (fixups already resolved in-place)
        def _copy_children(src_node, dst_node):
            for child in src_node.child_nodes.values():
                child_copy = copy.deepcopy(child)
                dst_node.add(child_copy)

        _copy_children(overlay_child, clean_node)
        result_nodes.append(clean_node)

    return result_nodes


def extract_overlay_targets_from_tree(overlay_tree):
    """Extract overlay targets by analyzing a compiled overlay tree.

    A dtc plugin-compiled overlay has the structure:

        fragment@N { target = <&label>; __overlay__ { props; child_nodes; }; };
        __fixups__ { label = "/fragment@N:target:0"; other_label = "..."; };

    __fixups__ maps every &label reference to its location. The target
    label for fragment@N appears as the entry whose path contains ':target:'.
    All other entries are phandle fixups inside the overlay body.

    Args:
        overlay_tree: A LopperTree loaded from a compiled overlay DTB

    Returns:
        dict: Mapping of label name to a dict with keys:
              'props'    - list of direct property names on the overlay target
              'children' - list of direct child node names added by the overlay
              Example: {'mmi_dc': {'props': ['status', 'clocks'],
                                   'children': ['ports']}}

    Note:
        The return value is a richer type than the regex fallback.
        Callers that only need property names should iterate result[label]['props'].
    """
    targets = {}

    try:
        # Build fragment-path -> target-label mapping from __fixups__ node.
        # __fixups__ property values are strings like "/fragment@0:target:0".
        # The target label is the property name; the fragment path precedes the first ':'.
        fragment_to_label = {}
        fixups_node = overlay_tree.nodes('/__fixups__')
        if fixups_node:
            fixups_node = fixups_node[0] if isinstance(fixups_node, list) else fixups_node
            for prop in fixups_node.__props__.values():
                label = prop.name
                # prop.value may be a string or list of strings
                val = prop.value
                if not isinstance(val, list):
                    val = [val]
                for path_str in val:
                    if not isinstance(path_str, str):
                        continue
                    # path_str like "/fragment@0:target:0"
                    # or "/fragment@0/__overlay__/child:some-prop:0"
                    frag_path = path_str.split(':')[0]   # "/fragment@0"
                    frag_name = frag_path.strip('/')      # "fragment@0"
                    if frag_path.count('/') == 1 and ':target:' in path_str:
                        fragment_to_label[frag_name] = label
                        break

        # Walk each top-level fragment@N node using the tree abstraction
        for node in overlay_tree:
            frag_name = node.name
            if not frag_name.startswith('fragment@'):
                continue
            if frag_name not in fragment_to_label:
                continue

            label = fragment_to_label[frag_name]

            # Find the __overlay__ child of this fragment.
            # child_nodes keys are full paths; search by node name.
            overlay_node = None
            for child_path, child in node.child_nodes.items():
                if child.name == '__overlay__':
                    overlay_node = child
                    break
            if overlay_node is None:
                continue

            # Collect direct property names from __overlay__
            props = list(overlay_node.__props__.keys())

            # Collect direct child node names from __overlay__
            children = [child.name for child in overlay_node.child_nodes.values()]

            if label in targets:
                targets[label]['props'].extend(props)
                targets[label]['children'].extend(children)
            else:
                targets[label] = {'props': props, 'children': children}

    except Exception as e:
        lopper.log._warning(f"Failed to extract overlay targets from tree: {e}")

    return targets


def compile_overlay_standalone(overlay_file, include_paths="", tmpdir=None, save_temps=False):
    """Compile an overlay file standalone using dtc's plugin support.

    Uses dtc's native overlay compilation with /plugin/ directive.
    The overlay is compiled standalone - dtc creates __fixups__ entries
    for unresolved labels rather than requiring the base tree.

    Args:
        overlay_file: Path to the overlay DTS/DTSI file
        include_paths: Include paths for preprocessing
        tmpdir: Directory for temporary files. If None, a temporary directory
                is created and cleaned up automatically unless save_temps is set.
        save_temps: If True, compiled artifacts are kept on disk. If False
                    (default), a temporary directory is created and deleted
                    after the tree is loaded.

    Returns:
        LopperTree or None: Compiled tree if successful, None on failure
    """
    # Only overlay files can be compiled as plugins
    if not is_overlay_file(overlay_file):
        return None

    def _compile(work_dir):
        overlay_basename = os.path.basename(overlay_file)
        plugin_file = os.path.join(work_dir, f"_plugin_{overlay_basename}")

        with open(overlay_file, 'r') as f:
            content = f.read()

        # Add /dts-v1/ and /plugin/ if not present
        has_dts_v1 = '/dts-v1/' in content
        has_plugin = '/plugin/' in content

        with open(plugin_file, 'w') as f:
            if not has_dts_v1:
                f.write('/dts-v1/;\n')
            if not has_plugin:
                f.write('/plugin/;\n\n')
            f.write(content)

        overlay_dir = os.path.dirname(overlay_file)
        full_includes = f"{include_paths} {overlay_dir}".strip()

        dtb_file, _ = Lopper.dt_compile(
            plugin_file, [], full_includes,
            force_overwrite=True, outdir=work_dir,
            save_temps=save_temps, verbose=0, enhanced=False,
            permissive=True, symbols=False
        )

        if dtb_file and os.path.exists(dtb_file):
            overlay_tree = LopperTree()
            fdt = Lopper.dt_to_fdt(dtb_file)
            overlay_tree.load(Lopper.export(fdt))
            return overlay_tree

        return None

    try:
        if tmpdir is not None or save_temps:
            # Caller-supplied dir, or save_temps: no automatic cleanup
            work_dir = tmpdir if tmpdir is not None else tempfile.mkdtemp()
            return _compile(work_dir)
        else:
            with tempfile.TemporaryDirectory() as work_dir:
                return _compile(work_dir)

    except Exception as e:
        lopper.log._debug(f"Compiled overlay analysis failed for {overlay_file}: {e}")

    return None


def extract_overlay_targets(overlay_file, include_paths="", tmpdir=None, save_temps=False):
    """Extract which nodes and properties an overlay file targets.

    Uses dtc's native plugin compilation to compile the overlay standalone,
    then analyzes the resulting tree. Falls back to brace-counting parser if
    compilation fails.

    Args:
        overlay_file: Path to the overlay DTS/DTSI file
        include_paths: Include paths for preprocessing (when compiling)
        tmpdir: Temporary directory for compilation artifacts

    Returns:
        dict: Mapping of label names to a dict with keys:
              'props'    - list of direct property names modified by the overlay
              'children' - list of direct child node names added by the overlay
              Example: {'mmi_dc': {'props': ['status', 'clocks'],
                                   'children': ['ports']}}
    """
    overlay_tree = compile_overlay_standalone(
        overlay_file, include_paths, tmpdir, save_temps
    )
    if overlay_tree:
        targets = extract_overlay_targets_from_tree(overlay_tree)
        if targets:
            lopper.log._debug(f"Used compiled analysis for {overlay_file}")
            return targets

    # Fallback to regex parsing
    lopper.log._debug(f"Using regex fallback for {overlay_file}")
    return _extract_overlay_targets_regex(overlay_file)


def _serialize_overlay_node(ov_node):
    """Encode one overlay LopperNode as a JSON-serialisable dict.

    Format:
      { "props": [[name, value], ...],
        "delete": [prop_name, ...],
        "children": [<recursive>, ...] }
    """
    props = []
    for prop_name, prop in ov_node.__props__.items():
        try:
            val = prop.value
        except Exception:
            val = []
        if not isinstance(val, list):
            val = [val]
        # Convert bytes objects so json.dumps can handle them
        serialisable = []
        for v in val:
            if isinstance(v, (bytes, bytearray)):
                serialisable.append(list(v))
            else:
                serialisable.append(v)
        pclass = prop.pclass if isinstance(prop.pclass, str) else ""
        props.append([prop_name, serialisable, pclass])

    delete = list(ov_node.__dict__.get('_props_to_delete', set()))

    children = []
    for child in list(ov_node.child_nodes.values()):
        children.append(_serialize_overlay_node(child))

    return {"abs_path": ov_node.abs_path,
            "props": props,
            "delete": delete,
            "children": children}


def _deserialize_overlay_node(data, parent=None, tree=None):
    """Reconstruct a LopperNode from the dict produced by _serialize_overlay_node."""
    from lopper.tree import LopperNode, LopperProp
    node = LopperNode(-1, data["abs_path"])
    node.tree = tree
    node.parent = parent

    for prop_name, val, pclass in data.get("props", []):
        lp = LopperProp(prop_name, -1, node, val)
        if pclass:
            lp.pclass = pclass
        node.__props__[prop_name] = lp

    to_delete = set(data.get("delete", []))
    if to_delete:
        node.__dict__['_props_to_delete'] = to_delete

    for child_data in data.get("children", []):
        child = _deserialize_overlay_node(child_data, parent=node, tree=tree)
        node.child_nodes[child.abs_path] = child

    return node


def _serialize_overlay_subtrees(tree, keep_embedded=False):
    """Embed overlay_subtrees into tree as /__lopper-overlays__ before DTS output.

    Each condition gets a child node; each overlay node within that condition
    becomes a property whose name is the path-encoded abs_path and whose value
    is a JSON string of the serialised node data.

    keep_embedded: when True, re-embed even if the overlays were loaded from a
    prior DTS embed.  Use this (via --emit-embedded-overlays) when more than
    two passes are needed and the embed must survive each intermediate write.
    """
    from lopper.tree import LopperNode, LopperProp
    overlay_subtrees = tree._metadata.get('overlay_subtrees', {})
    if not overlay_subtrees:
        return
    # If overlays were loaded from a prior DTS embed they have already been
    # consumed by this pass; do not re-embed so final output is clean —
    # unless the caller explicitly requests persistence across extra passes.
    if tree._metadata.get('_overlay_subtrees_from_embed') and not keep_embedded:
        return

    root_node = LopperNode(-1, '/__lopper-overlays__')
    tree.add(root_node, dont_sync=True)

    for condition_name, node_list in overlay_subtrees.items():
        cond_node = LopperNode(-1, f'/__lopper-overlays__/{condition_name}')
        tree.add(cond_node, dont_sync=True)

        for ov_node in node_list:
            prop_name = Lopper.path_to_prop_name(ov_node.abs_path)
            encoded = json.dumps(_serialize_overlay_node(ov_node))
            lp = LopperProp(prop_name, -1, cond_node, encoded)
            cond_node.__props__[prop_name] = lp
            # Resolve immediately so the prop has a valid ptype before print().
            # _serialize runs after tree.resolve(), so new nodes need explicit
            # resolution; without it they show as **unresolved** in DTS output.
            lp.resolve(strict=False)


def _deserialize_overlay_subtrees(tree):
    """Reconstruct overlay_subtrees from /__lopper-overlays__ after DTS load.

    After reconstruction the internal node is removed so it never appears
    in subsequent output files.
    """
    if '/__lopper-overlays__' not in tree.__nodes__:
        return

    root_node = tree['/__lopper-overlays__']
    overlay_subtrees = tree._metadata.setdefault('overlay_subtrees', {})

    for cond_node in list(root_node.child_nodes.values()):
        condition_name = cond_node.name
        nodes_for_cond = overlay_subtrees.setdefault(condition_name, [])

        for prop_name, lp in cond_node.__props__.items():
            try:
                data = json.loads(lp.value if isinstance(lp.value, str) else lp.value[0])
            except Exception:
                continue
            abs_path = Lopper.prop_name_to_path(prop_name)
            ov_node = _deserialize_overlay_node(data, tree=tree)
            ov_node.__dict__['abs_path'] = abs_path
            nodes_for_cond.append(ov_node)

    # Remove the internal bookkeeping node from the tree so it never appears
    # in subsequent output, and mark the source so _serialize_overlay_subtrees
    # knows not to re-embed on write (overlays already consumed from DTS embed).
    try:
        tree.delete(root_node)
    except Exception:
        pass
    tree._metadata['_overlay_subtrees_from_embed'] = True


def _write_overlay_sidecar(tree, output_path):
    """Distil overlay_subtrees back to a minimal sigil YAML file.

    The sidecar is a valid lopper YAML input: users pass it with -i on a
    subsequent invocation to restore overlay_subtrees.  The existing
    LopperYAML.to_tree() sigil parser reloads it — no new deserialization.

    Output path: <stem>.overlays.yaml (or <stem>.<ext>.overlays.yaml).
    """
    overlay_subtrees = tree._metadata.get('overlay_subtrees', {})
    if not overlay_subtrees:
        return

    stem = Path(output_path).stem
    parent = Path(output_path).parent
    sidecar_path = str(parent / f"{stem}.overlays.yaml")

    # Rebuild nested-dict sigil YAML from overlay nodes.
    # Each overlay node's props become  property!condition: value  entries
    # under the node's path hierarchy.
    data = {}

    def _set_nested(d, keys, value):
        for k in keys[:-1]:
            d = d.setdefault(k, {})
        d[keys[-1]] = value

    for condition_name, node_list in overlay_subtrees.items():
        for ov_node in node_list:
            # Reconstruct path components as nested-dict keys
            parts = [p for p in ov_node.abs_path.split('/') if p]
            for prop_name, lp in ov_node.__props__.items():
                sigil_key = f"{prop_name}!{condition_name}"
                val = lp.value
                if isinstance(val, list) and len(val) == 1:
                    val = val[0]
                _set_nested(data, parts + [sigil_key], val)
            for prop_name in ov_node.__dict__.get('_props_to_delete', set()):
                sigil_key = f"{prop_name}!{condition_name}!delete"
                _set_nested(data, parts + [sigil_key], None)

    # Write the sidecar. Prepend a lopper-overlays: marker comment so the
    # YAML classification logic routes this file through to_tree() as an
    # sdt_file rather than a support_file on reload — without injecting a
    # spurious tree node.
    try:
        import ruamel.yaml as _ruamel
        import io as _io
        _yaml_engine = _ruamel.YAML()
        buf = _io.StringIO()
        _yaml_engine.dump(data, buf)
        yaml_body = buf.getvalue()
    except Exception:
        import yaml as _pyyaml
        yaml_body = _pyyaml.dump(data, default_flow_style=False)
    with open(sidecar_path, 'w') as f:
        f.write("# lopper-overlays: generated sigil overlay sidecar\n")
        f.write(yaml_body)
    lopper.log._info(f"overlay sidecar written: {sidecar_path}")


def _write_overlay_dtso_files(tree, output_path):
    """Emit one .dtso overlay file per condition in overlay_subtrees (opt-in).

    Each file is a standard DTS overlay that, when applied, reproduces the
    conditional property overrides for that condition.  Merge schemes
    (append/prepend/delete) are not representable in DTS overlays — the
    output is always replace-mode.  This is documented inside the emitted file.

    Output: <stem>.<condition>.dtso per condition.
    """
    overlay_subtrees = tree._metadata.get('overlay_subtrees', {})
    if not overlay_subtrees:
        return

    stem = Path(output_path).stem
    parent = Path(output_path).parent

    for condition_name, node_list in overlay_subtrees.items():
        dtso_path = str(parent / f"{stem}.{condition_name}.dtso")
        lines = [
            "// Auto-generated DTS overlay for condition: " + condition_name,
            "// NOTE: merge schemes (append/prepend/delete) are not expressible in",
            "// DTS overlays — all property overrides are replace-mode here.",
            "/dts-v1/;",
            "/plugin/;",
            "",
        ]
        for ov_node in node_list:
            # Emit a fragment using the node's label if available, else path
            label = ov_node.label if hasattr(ov_node, 'label') and ov_node.label else None
            ref = f"&{label}" if label else f"&{{  /* {ov_node.abs_path} */ }}"
            lines.append(f"&{label or 'LABEL_UNKNOWN'} {{")
            for prop_name, lp in ov_node.__props__.items():
                val = lp.value
                if isinstance(val, list) and len(val) == 1:
                    val = val[0]
                if isinstance(val, str):
                    lines.append(f'\t{prop_name} = "{val}";')
                elif isinstance(val, int):
                    lines.append(f'\t{prop_name} = <{hex(val)}>;')
                else:
                    lines.append(f'\t/* {prop_name}: complex value, omitted */')
            lines.append("};")
            lines.append("")

        with open(dtso_path, 'w') as f:
            f.write('\n'.join(lines))
        lopper.log._info(f"overlay dtso written: {dtso_path}")


class LopperSDT:
    """The LopperSDT Class represents and manages the full system DTS file

    In particular this class:
      - wraps a dts/dtb/fdt containing a system description
      - Has a LopperTree representation of the system device tree
      - manages and applies operations to the tree
      - calls modules and assist functions for processing of that tree

    Attributes:
      - dts (string): the source device tree file
      - dtb (blob): the compiled dts
      - FDT (fdt): the primary flattened device tree represention of the dts
      - lops (list): list of loaded lopper operations
      - verbose (int): the verbosity level of operations
      - tree (LopperTree): node/property representation of the system device tree
      - dryrun (bool): whether or not changes should be written to disk
      - output_file (string): default output file for writing

    """
    def __init__(self, sdt_file):
        self.dts = sdt_file
        self.dtb = ""
        self.lops = []
        self.lops_optional = []
        self.verbose = 0
        self.dryrun = False
        self.assists = []
        self.output_file = ""
        self.cleanup_flag = True
        self.save_temps = False
        self.enhanced = False
        self.FDT = None
        self.tree = None
        self.subtrees = {}
        self.outdir = "./"
        self.tmpdir = tempfile.mkdtemp()
        self.target_domain = ""
        self.load_paths = []
        self.permissive = False
        self.merge = False
        self.support_files = False
        self.symbols = False
        self.warnings = []
        self.werror = False
        self.tmpfiles = []
        self.schema = None
        self.overlay_emit = set()

    def _compile_overlay_subtrees(self, overlay_dts_files, include_paths):
        """Compile each overlay DTS into a named node list for lazy merging.

        Each overlay file is keyed by its filename without extension (e.g.
        'my-overlay' for 'my-overlay.dtso').  Nodes are stored under that key
        in self.tree._metadata['overlay_subtrees'] so callers can retrieve a
        fully merged copy via self.tree.overlay_tree('my-overlay') without
        modifying the base tree.

        Args:
           overlay_dts_files (list): paths to overlay DTS/DTSO files to compile
           include_paths (list): include paths passed to dtc during compilation

        Returns:
           Nothing

        """
        if not overlay_dts_files or self.tree is None:
            return

        skip_paths = {'/', '__fixups__', '/__fixups__',
                      '__local_fixups__', '/__local_fixups__',
                      '__symbols__', '/__symbols__'}

        for overlay_file in overlay_dts_files:
            overlay_name = os.path.basename(overlay_file)
            stem = os.path.splitext(overlay_name)[0]

            lopper.log._info(f"Registering overlay '{stem}' from {overlay_name}")

            ov_tree = compile_overlay_standalone(
                overlay_file,
                include_paths=include_paths,
                tmpdir=self.tmpdir,
                save_temps=self.save_temps
            )

            if ov_tree is None:
                lopper.log._error(f"Could not compile overlay {overlay_name}")
                sys.exit(1)

            # Unwrap the dtc plugin structure (fragment@N/__overlay__) into
            # clean nodes at their real target paths with phandles resolved
            # against the base tree.  Assists see normal LopperNode objects;
            # no knowledge of dtc's internal compilation format is needed.
            nodes = _unwrap_overlay_tree(ov_tree, self.tree)
            self.tree._metadata.setdefault('overlay_subtrees', {})[stem] = nodes

            lopper.log._debug(f"Registered {len(nodes)} overlay nodes for '{stem}'")

    def subtrees_sync(self):
        """Populate subtrees dict from self.tree._metadata['child_trees']

        Copies child trees (extracted, overlays) tracked on the main SDT tree
        into the subtrees dict so they're accessible by name. This allows
        assists and other code to access these trees via sdt.subtrees['name'].

        Called after assists run or when subtrees dict access is needed.

        Note: Operates on self.tree (the main SDT tree), not arbitrary trees.
        """
        if self.tree is None:
            return

        for child in self.tree._metadata.get('child_trees', []):
            name = child._metadata.get('name')
            if name and name not in self.subtrees:
                self.subtrees[name] = child
                lopper.log._debug( f"Synced subtree '{name}' from tree metadata" )

    def setup(self, sdt_file, input_files, include_paths, force=False, libfdt=True, config=None):
        """executes setup and initialization tasks for a system device tree

        setup validates the inputs, and calls the appropriate routines to
        preprocess and compile passed input files (.dts).

        Args:
           sdt_file (String): system device tree path/file
           input_files (list): list of input files (.dts, or .dtb) in addition to the sdt_file
           include_paths (list): list of paths to search for files
           force (bool,optional): flag indicating if files should be overwritten and compilation
                                  forced. Default is False.

        Returns:
           Nothing

        """
        if libfdt:
            lopper.log._info( f"loading dtb and using libfdt to manipulate tree" )

        if sdt_file:
            # check for required support applications
            if libfdt:
                support_bins = [ os.environ.get("LOPPER_DTC", "dtc") ]
            else:
                support_bins = []

            # cpp or pcpp is required for both libfdt and non-libfdt modes
            preprocessor = (os.environ.get('LOPPER_CPP') or
                                           shutil.which("cpp") or
                                           shutil.which("pcpp") or
                                           shutil.which("pcpp-python") or "").split()
            support_bins.extend( preprocessor )
            for s in support_bins:
                lopper.log._info( f"checking for support binary: {s}" )
                if not shutil.which(s):
                    lopper.log._error( f"support application '{s}' not found, exiting" )
                    sys.exit(2)

        self.use_libfdt = libfdt

        current_dir = os.getcwd()

        # is the tmpdir valid ?
        tmpdir_path =  Path( self.tmpdir )
        if tmpdir_path.is_dir():
            # do nothing, the tempdir is still present
            pass
        else:
            # atexit() has run, so we need to create it again ..
            self.tmpdir = tempfile.mkdtemp()

        self.lops = []
        self.input_files = []
        self.assists = []

        # a) Ensure each file appears only once
        unique_files = list(set(input_files))
        if len(unique_files) < len(input_files):
            lopper.log._debug( "some files were duplicated in the input list." )
            input_files = unique_files

        # Set of basenames for lookup
        basenames = {os.path.basename(f) for f in unique_files}

        incompatible_pairs = set()
        for file in unique_files:
            try:
                ifile_searched = self.input_find( file )
                if not ifile_searched:
                    raise FileNotFoundError

                with open(ifile_searched) as fptr:
                    for line in fptr:
                        lstripped = line.strip()
                        if lstripped.startswith('incompatible ='):
                            # Get everything after '='
                            rhs = lstripped.split('=', 1)[1].strip()
                            # Remove trailing semicolon (DTS syntax)
                            rhs = rhs.rstrip(';').strip()
                            other_names = [name.strip().strip('"') for name in rhs.split(',')]
                            for other_name in other_names:
                                if other_name in basenames:
                                    incompatible_pairs.add((os.path.basename(file), other_name))
                            break  # Only one incompatible property per file
            except FileNotFoundError:
                sys.exit(1)
            except Exception as e:
                lopper.log._error(f"occurred while processing the file '{file}': {e}")

        # Convert the set of incompatible pairs to a list
        incompatible_list = list(incompatible_pairs)

        if incompatible_list:
            lopper.log._error("Incompatible files found. One must be removed from processing:")
            for file1, file2 in incompatible_list:
                lopper.log._error(f"- {file1} is incompatible with {file2}")
            sys.exit(1)
        else:
            lopper.log._debug("No incompatible files found")

        lop_files = []
        sdt_files = []
        support_files = []
        for ifile in input_files:
            ifile_searched = self.input_find( ifile )
            if not ifile_searched:
                lopper.log._warning( f"input file {ifile} not found")
                continue
            else:
                ifile = ifile_searched

            if re.search( r".dts$", ifile ) or re.search( r".dtsi$", ifile ) or \
               re.search( f".lop$", ifile ):
                # an input file is either a lopper operation file, or part of the
                # system device tree. We can check for compatibility to decide which
                # it is.
                with open(ifile) as f:
                    datafile = f.readlines()
                    found = False
                    for line in datafile:
                        if not found:
                            if re.search( r"system-device-tree-v1,lop", line ):
                                lop_files.append( ifile )
                                found = True

                if not found:
                    sdt_files.append( ifile )
            elif re.search( r".dtb$", ifile ):
                lop_files.append( ifile )
            elif re.search( r".yaml$", ifile ) or re.search( f".json$", ifile):
                if yaml_support:
                    with open(ifile) as f:
                        datafile = f.readlines()
                        found = False
                        dts_found = False
                        for line in datafile:
                            if not found:
                                if re.search( r"system-device-tree-v1,lop", line ):
                                    lop_files.append( ifile )
                                    found = True
                                if re.search( r"/dts-v1/", line ):
                                    found = True
                                    sdt_files.append( ifile )
                                if re.search( r"compatible: .*subsystem", line ) or \
                                   re.search( r",domain-v1", line ) or \
                                   re.search( r"lopper-overlays:", line ):
                                    sdt_files.append( ifile )
                                    found = True

                    # it didn't have a dts identifier in the input json or yaml file
                    # so it a supporting input. We need to store it as such.
                    if not found:
                        support_files.append( ifile )
                else:
                    lopper.log._error( f"YAML/JSON support is not loaded, check dependencies" )
                    sys.exit(1)
            else:
                lopper.log._error( f"input file {ifile} cannot be processed (no handler)" )
                sys.exit(1)

        # is the sdt a dts ?
        sdt_extended_trees = []
        if self.dts and re.search( r".dts$", self.dts ):
            # do we have any extra sdt files to concatenate first ?
            fp = ""
            fpp = tempfile.NamedTemporaryFile( delete=False )
            overlay_dts_files = []
            # TODO: if the count is one, we shouldn't be doing the tmp file processing.
            if sdt_files:
                sdt_files.insert( 0, self.dts )

                # Identify overlay files and extract their targets for source tracking.
                # This allows us to tag properties that came from user overlays
                # after the full compilation is complete.
                #
                # Uses compiled tree analysis when possible (per design) - compiles
                # overlay with base SDT context for label resolution, then analyzes
                # the resulting tree. Falls back to regex parsing if compilation fails.
                self._overlay_targets = {}
                for f in sdt_files:
                    if (f.endswith(".dts") or f.endswith(".dtsi")) and f != self.dts:
                        if is_overlay_file(f):
                            overlay_name = os.path.basename(f)
                            targets = extract_overlay_targets(
                                f,
                                include_paths=include_paths,
                                tmpdir=self.tmpdir,
                                save_temps=self.save_temps
                            )
                            if targets:
                                self._overlay_targets[overlay_name] = targets
                                lopper.log._debug(f"Identified overlay {overlay_name} targeting: {list(targets.keys())}")

                # Separate overlay DTS files from base DTS files so that
                # base and overlay layers can be seeded independently.
                # Overlay files use &label { } syntax to modify existing nodes.
                overlay_dts_files = []
                base_dts_files = []
                for f in sdt_files:
                    if f.endswith(".dts") or f.endswith(".dtsi"):
                        if f != self.dts and is_overlay_file(f):
                            overlay_dts_files.append(f)
                        else:
                            base_dts_files.append(f)

                # Concatenate only base DTS files into the file to compile.
                # Overlay files will be compiled separately after the base tree
                # is loaded so that base and overlay layers are properly seeded.
                with open( fpp.name, 'wb') as wfd:
                    for f in base_dts_files:
                        with open(f, 'rb') as fd:
                            shutil.copyfileobj(fd, wfd)

                # Handle yaml/json extended trees from all sdt_files
                for f in sdt_files:
                    if re.search( r".yaml$", f ):
                        # Note: if tree merging isn't sufficient for these, we could use
                        #       deepmerge functionality directly on mutltiple yaml files
                        # look for a special front end, for this or any file for that matter
                        yaml = LopperYAML( f, config=config )
                        yaml_tree = yaml.to_tree()
                        yaml_tree._type = "yaml"

                        # save the tree for future processing (and joining with the main
                        # system device tree). No code after this needs to be concerned that
                        # this came from yaml.
                        sdt_extended_trees.append( yaml_tree )
                    elif re.search( r".json$", f ):
                        # look for a special front end, for this or any file for that matter
                        json = LopperJSON( json=f, config=config )
                        json_tree = json.to_tree()
                        json_tree._type = "json"

                        # save the tree for future processing (and joining with the main
                        # system device tree). No code after this needs to be concerned that
                        # this came from yaml.
                        sdt_extended_trees.append( json_tree )


                fp = fpp.name
            else:
                sdt_files.append( sdt_file )
                fp = sdt_file

            # note: input_files isn't actually used by dt_compile, otherwise, we'd need to
            #       filter out non-dts files before the call .. we should probably still do
            #       that.
            sdt_file = Path( sdt_file )
            sdt_file_abs = resolve_path_with_drive_preservation(sdt_file)

            # we need the original location of the main SDT file on the search path
            # in case there are dtsi files, etc.
            include_paths += " " + str(sdt_file.parent) + " "

            # Note: we use the tmpdir vs the outdir here, since these are files that don't
            #       need to be kept. The outdir will be used for the main writing of a transformed
            #       SDT.
            self.dtb, schema = Lopper.dt_compile( fp, input_files, include_paths, force, self.tmpdir,
                                                  self.save_temps, self.verbose, self.enhanced, self.permissive,
                                                  self.symbols )

            # Determine if we're in a learning mode
            is_learning = (self.schema == "learn" or
                           (isinstance(self.schema, tuple) and self.schema[0] == "learn_dump"))

            if is_learning:
                # Store the output path if we need to dump
                output_path = None
                if isinstance(self.schema, tuple):
                    _, output_path = self.schema

                # Common learning setup
                self.schema = schema
                lopper.schema.initialize_lopper_properties( self.schema )
                lopper.schema._schema_manager.update_schema( self.schema )
                lopper.log._info( f"schema learning complete")
                lopper.log._debug( f"schema: {self.schema}")

                # Dump if requested
                if output_path:
                    try:
                        import yaml

                        # Custom representer for multi-line strings
                        def str_presenter(dumper, data):
                            if len(data.splitlines()) > 1:  # Multi-line string
                                return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
                            return dumper.represent_scalar('tag:yaml.org,2002:str', data)

                        yaml.add_representer(str, str_presenter)

                        # Better formatting for None values
                        def none_representer(dumper, data):
                            return dumper.represent_scalar('tag:yaml.org,2002:null', '')

                        yaml.add_representer(type(None), none_representer)

                        if output_path == "-":
                            # Write to stdout
                            yaml.dump(self.schema, sys.stdout,
                                     default_flow_style=False,
                                     sort_keys=False,
                                     width=120)
                            lopper.log._info(f"Schema written to stdout")
                        else:
                            # Write to file
                            with open(output_path, 'w') as f:
                                yaml.dump(self.schema, f,
                                         default_flow_style=False,
                                         sort_keys=False,
                                         width=120)
                            lopper.log._info(f"Schema written to {output_path}")

                    except Exception as e:
                        lopper.log._error(f"Failed to write schema: {e}")
                        sys.exit(1)

            elif self.schema == None:
                # do nothing. We may eventually do some very minimal hints
                # in this scenario
                pass
            else:
                # this is a schema file, we don't currently have support for
                # this, so output a warning
                lopper.log._warning( f"schema file {self.schema}, but external schemas are not supported yet" )

            if self.use_libfdt:
                self.FDT = Lopper.dt_to_fdt(self.dtb, 'rb')
            else:
                lopper.log._info( f"using python devicetree for parsing" )

                # TODO: "FDT" should now be "token" or something equally generic
                self.FDT = self.dtb
                self.dtb = ""

            dct = Lopper.export( self.FDT, schema = self.schema )

            self.tree = LopperTree()
            self.tree.warnings = self.warnings
            self.tree.werror = self.werror
            self.tree.strict = not self.permissive
            self.tree.schema = self.schema
            self.tree.load( dct )
            _deserialize_overlay_subtrees( self.tree )

            self.tree.__dbg__ = self.verbose

            # Do a check for common sanity issues here, invalid phandles, etc.
            self.tree.resolve( check=True )

            self.tree.__symbols__ = self.symbols

            # Compile each overlay file into a named node list so the base tree
            # can produce a fully merged copy on demand via overlay_tree(name).
            if overlay_dts_files:
                self._compile_overlay_subtrees(overlay_dts_files, include_paths)

            # join any extended trees to the one we just created
            for t in sdt_extended_trees:
                for node in t['/'].children():
                    if node.abs_path != "/":
                        # old: deep copy the node
                        # new_node = node()
                        # assign it to the main system device tree
                        merge = self.merge
                        try:
                            if self.config['dts']['merge']:
                                merge = True
                        except:
                            pass

                        # we always merge yaml, but this could become
                        # configurable in the future
                        if t._type == "yaml":
                            merge = True

                        self.tree = self.tree.add( node, merge=merge )

                # When a YAML file contains sigil syntax (e.g. compatible!linux),
                # to_tree() collects the conditional LopperNodes into
                # t._metadata['overlay_subtrees'][condition_name].  Those nodes
                # are not part of the base tree; they are kept separate so
                # overlay_tree(name) can merge them on demand.  After joining
                # the YAML nodes into the main DTS tree we must also copy this
                # per-condition node registry onto the main tree, otherwise
                # overlay_tree() won't find any overlays to apply.
                if t._type == "yaml":
                    yaml_cond_nodes = t._metadata.get('overlay_subtrees', {})
                    main_cond_nodes = self.tree._metadata.setdefault('overlay_subtrees', {})
                    for condition_name, overlay_nodes in yaml_cond_nodes.items():
                        if condition_name not in main_cond_nodes:
                            main_cond_nodes[condition_name] = []
                        main_cond_nodes[condition_name].extend(overlay_nodes)

            fpp.close()
            self.tmpfiles.append( fpp.name )


        elif self.dts and re.search( r".yaml$", self.dts ):
            if not yaml_support:
                lopper.log._error( f"no yaml support detected, but input is yaml" )
                sys.exit(1)

            fp = ""
            fpp = tempfile.NamedTemporaryFile( delete=False )
            if sdt_files:
                sdt_files.insert( 0, self.dts )

                lopper.log._debug( f"merging yaml: {sdt_files}" )

                merged_data = {}
                for file_path in sdt_files:
                    # Load the current YAML file
                    current_data = LopperYAML.yaml_to_data(file_path)

                    # Merge the current data with the accumulated merged_data
                    merged_data = LopperYAML.deep_merge(merged_data, current_data)

                LopperYAML.data_to_yaml( merged_data, fpp.name )

                fp = fpp.name
            else:
                sdt_files.append( sdt_file )
                fp = sdt_file

            yaml = LopperYAML( fp, config=config )
            lt = yaml.to_tree()

            self.dtb = None
            if self.use_libfdt:
                self.FDT = Lopper.fdt()
            else:
                self.FDT = None
            self.tree = lt

            self.tree.strict = not self.permissive

            fpp.close()
            self.tmpfiles.append( fpp.name )

        elif self.dts and re.search( r".json$", self.dts ):
            if not yaml_support:
                lopper.log._error( f"no json detected, but system device tree is json" )
                sys.exit(1)

            fp = ""
            fpp = tempfile.NamedTemporaryFile( delete=False )
            if sdt_files:
                sdt_files.insert( 0, self.dts )

                # this block concatenates all the files into a single yaml file to process
                with open( fpp.name, 'wb') as wfd:
                    for f in sdt_files:
                        with open(f,'rb') as fd:
                            shutil.copyfileobj(fd, wfd)

                fp = fpp.name
            else:
                sdt_files.append( sdt_file )
                fp = sdt_file

            json = LopperJSON( json=fp, config=config )
            lt = json.to_tree()

            self.dtb = None
            if self.use_libfdt:
                self.FDT = Lopper.fdt()
            else:
                self.FDT = None
            self.tree = lt
            self.tree.warnings = self.warnings
            self.tree.werror = self.werror
            self.tree.strict = not self.permissive

            # Do a check for common sanity issues here, invalid phandles, etc.
            self.tree.resolve( check=True )

            fpp.close()
            self.tmpfiles.append( fpp.name )
        else:
            if self.dts:
                # the system device tree is a dtb
                self.dtb = sdt_file
                self.dts = sdt_file
                if not self.use_libfdt:
                    lopper.log._error( f"dtb system device tree passed ({self.dts}), and libfdt is disabled" )
                    sys.exit(1)
                self.FDT = Lopper.dt_to_fdt(self.dtb, 'rb')
                self.tree = LopperTree()
                self.tree.warnings = self.warnings
                self.tree.werror = self.werror
                self.tree.strict = not self.permissive
                self.tree.load( Lopper.export( self.FDT ) )
                _deserialize_overlay_subtrees( self.tree )

                # Do a check for common sanity issues here, invalid phandles, etc.
                self.tree.resolve( check=True )

        try:
            lops = self.tree["/lops"]
            if lops:
                lopper.log._info( f"embedded lops detected, extracting and queuing" )

                # free the lops from the input tree
                self.tree.delete(lops)

                # and save them in a lops tree
                embedded_lops_tree = LopperTree()
                embedded_lops_tree + lops

                # promote any priority designation from the /lops node
                # to our root node, since that is where future processing
                # will look (we can't do this at "/" in embedded lops as
                # the "/" node is added to the main root node of the entire
                # tree by dtc
                try:
                    lops_node = embedded_lops_tree["/lops"]
                    priority = lops_node["priority"]
                    embedded_lops_tree["/"] + priority
                    lops_node - priority
                except Exception as e:
                    pass

                lop = LopperFile( "" )
                lop.dts = ""
                lop.dtb = ""
                lop.fdt = None
                lop.tree = embedded_lops_tree

                if self.autorun:
                    self.lops.append( lop )
                else:
                    self.lops_optional.append( lop )
        except Exception as e:
            pass

        self.support_files = support_files

        if self.verbose:
            search_paths = self.load_paths + [ lopper_directory ] + [ lopper_directory + "/assists/" ]
            print("")
            print("Lopper summary:")
            print( f"   system device tree: {sdt_files}" )
            print( f"   lops: {lop_files}" )
            print( f"   search paths: {search_paths}" )
            print( f"   output: {self.output_file}" )
            print("")

        # Individually compile the input files. At some point these may be
        # concatenated with the main SDT if dtc is doing some of the work, but for
        # now, libfdt is doing the transforms so we compile them separately
        for ifile in lop_files:
            if re.search( r".dts$", ifile ) or re.search( r".lop$", ifile ):
                lop = LopperFile( ifile )
                # TODO: this may need an output directory option, right now it drops
                #       it where lopper is called from (which may not be writeable.
                #       hence why our output_dir is set to "./"
                compiled_file, _  = Lopper.dt_compile( lop.dts, "", include_paths, force, self.tmpdir,
                                                       self.save_temps, self.verbose )
                if not compiled_file:
                    lopper.log._error( f"could not compile file {ifile}" )
                    sys.exit(1)

                if self.use_libfdt:
                    lop.dtb = compiled_file
                else:
                    lop.dtb = ""
                    lop.fdt = None
                    dct = Lopper.export( compiled_file )
                    lop.tree = LopperTree()
                    lop.tree.load( dct )

                self.lops.append( lop )
            elif re.search( r".yaml$", ifile ):
                yaml = LopperYAML( ifile, config=config )
                yaml_tree = yaml.to_tree()

                lop = LopperFile( ifile )
                lop.dts = ""
                lop.dtb = ""
                lop.fdt = None
                lop.tree = yaml_tree
                self.lops.append( lop )
            elif re.search( r".json$", ifile ):
                json = LopperJSON( json=ifile, config=config )
                json_tree = json.to_tree()

                lop = LopperFile( ifile )
                lop.dts = ""
                lop.dtb = ""
                lop.fdt = None
                lop.tree = json_tree
                self.lops.append( lop )
            elif re.search( r".dtb$", ifile ):
                lop = LopperFile( ifile )
                lop.dts = ""
                lop.dtb = ifile
                self.lops.append( lop )

    def assists_setup( self, assists = []):
        """
                   assists (list,optional): list of python assist modules to load. Default is []
        """
        for a in assists:
            a_file = self.assist_find( a )
            if a_file:
                self.assists.append( LopperAssist( str(resolve_path_with_drive_preservation(a_file)) ) )
            else:
                lopper.log._error( f"assist {a} not found" )
                os._exit(1)

        self.assists_wrap()

    def assist_autorun_setup( self, module_name, module_args = [] ):
        lt = LopperTree()

        lopper.log._debug( f"setting up module {module_name} with args:{module_args}" )

        lt['/']['compatible'] = [ 'system-device-tree-v1' ]
        lt['/']['priority'] = [ 3 ]

        ln = LopperNode()
        ln.name = "lops"

        mod_count = 0
        lop_name = f"lop_{mod_count}"

        lop_node = LopperNode()
        lop_node.name = lop_name
        lop_node['compatible'] = [ 'system-device-tree-v1,lop,assist-v1' ]
        lop_node['node'] = [ '/' ]

        if module_args:
            module_arg_string = ""
            for m in module_args:
                module_arg_string = module_arg_string + " " + m
                lop_node['options'] = [ module_arg_string ]

        lop_node['id'] = [ "module," + module_name ]

        ln = ln + lop_node
        lt = lt + ln

        lop = LopperFile( 'commandline' )
        lop.dts = ""
        lop.dtb = ""
        lop.fdt = None
        lop.tree = lt

        lopper.log._debug( f"generated assist run for {module_name}" )

        self.lops.insert( 0, lop )

    def cleanup( self ):
        """cleanup any temporary or copied files

        Either called directly, or registered as an atexit handler. Any
        temporary or copied files are removed, as well as other relevant
        cleanup.

        Args:
           None

        Returns:
           Nothing

        """
        # remove any .dtb and .pp files we created
        if self.cleanup_flag and not self.save_temps:
            try:
                if self.dtb != self.dts:
                    os.remove( self.dtb )
            except:
                pass
            try:
                if self.enhanced:
                    os.remove( self.dts + ".enhanced" )
            except:
                pass
            try:
                if self.tmpdir:
                    shutil.rmtree( self.tmpdir )
            except:
                pass
            try:
                # these are files that were used at some point
                # in the processing pipeline and were nominated
                # for final cleanup.
                if self.tmpfiles:
                    for f in self.tmpfiles:
                        os.remove( f )
            except:
                pass


        # note: we are not deleting assists .dtb files, since they
        #       can actually be binary blobs passed in. We are also
        #       not cleaning up the concatenated compiled. pp file, since
        #       it is created with mktmp()

    def write( self, tree = None, output_filename = None, overwrite = True, enhanced = False ):
        """Write a system device tree to a file

        Write a fdt (or system device tree) to an output file. This routine uses
        the output filename to determine if a module should be used to write the
        output.

        If the output format is .dts or .dtb, Lopper takes care of writing the
        output. If it is an unrecognized output type, the available assist
        modules are queried for compatibility. If there is a compatible assist,
        it is called to write the file, otherwise, a warning or error is raised.

        Args:
            tree (LopperTree,optional): LopperTree to write
            output_filename (string,optional): name of the output file to create
            overwrite (bool,optional): Should existing files be overwritten. Default is True.
            enhanced(bool,optional): whether enhanced printing should be performed. Default is False

        Returns:
            Nothing

        """
        if not output_filename:
            output_filename = self.output_file

        if not output_filename:
            return

        tree_to_write = tree
        if not tree_to_write:
            tree_to_write = self.tree

        if re.search( r"\.dtb$", output_filename ):
            if self.outdir and not Path( output_filename ).is_absolute():
                output_filename = self.outdir + "/" + output_filename

            if self.use_libfdt:
                fdt = Lopper.fdt()
                Lopper.sync( fdt, tree_to_write.export() )
                Lopper.write_fdt( fdt, output_filename, overwrite, self.verbose )
            else:
                lopper.log._error( f"dtb output selected ({output_filename}), but libfdt is not enabled" )
                sys.exit(1)

        elif re.search( r"\.dts$", output_filename ) or re.search( r"\.dtsi$", output_filename ) :
            if self.outdir and not Path( output_filename ).is_absolute():
                output_filename = self.outdir + "/" + output_filename

            o = Path(output_filename)
            if o.exists() and not overwrite:
                lopper.log._error( f"output file {output_filename} exists and force overwrite is not enabled" )
                sys.exit(1)

            try:
                if self.config['dts']['tabs']:
                    # printer.indent_char = '\t'
                    tree_to_write.indent_char = '\t'
                else:
                    # printer.indent_char = ' '
                    tree_to_write.indent_char = ' '
            except:
                pass

            tree_to_write.strict = not self.permissive
            tree_to_write.resolve()
            _serialize_overlay_subtrees(tree_to_write, keep_embedded='embedded' in self.overlay_emit)
            if 'sidecar' in self.overlay_emit:
                _write_overlay_sidecar(tree_to_write, output_filename)
            if 'dtso' in self.overlay_emit:
                _write_overlay_dtso_files(tree_to_write, output_filename)
            tree_to_write.print( output_filename )

        elif re.search( r"\.yaml$", output_filename ):
            if self.outdir and not Path( output_filename ).is_absolute():
                output_filename = self.outdir + "/" + output_filename

            o = Path(output_filename)
            if o.exists() and not overwrite:
                lopper.log._error( f"output file {output_filename} exists and force overwrite is not enabled"  )
                sys.exit(1)

            yaml = LopperYAML( None, tree_to_write, config=self.config )
            yaml.to_yaml( output_filename )
        elif re.search( r"\.json$", output_filename ):
            if self.outdir and not Path( output_filename ).is_absolute():
                output_filename = self.outdir + "/" + output_filename

            o = Path(output_filename)
            if o.exists() and not overwrite:
                lopper.log._error( f"output file {output_filename} exists and force overwrite is not enabled" )
                sys.exit(1)

            json = LopperYAML( None, self.tree, config=self.config )
            json.to_json( output_filename )
        else:
            if self.outdir and not Path( output_filename ).is_absolute():
                output_filename = self.outdir + "/" + output_filename

            # we use the outfile extension as a mask
            (out_name, out_ext) = os.path.splitext(output_filename)
            cb_funcs = self.find_compatible_assist( 0, "", out_ext )
            if cb_funcs:
                for cb_func in cb_funcs:
                    try:
                        out_tree = LopperTreePrinter( True, output_filename, self.verbose )
                        out_tree.load( tree_to_write.export() )
                        out_tree.strict = not self.permissive
                        if not cb_func( 0, out_tree, { 'outfile': output_filename, 'verbose' : self.verbose } ):
                            lopper.log._warning( f"output assist returned false, check for errors ..." )
                    except Exception as e:
                        lopper.log._warning( f"output assist {cb_func} failed: {e}" )
                        exc_type, exc_obj, exc_tb = sys.exc_info()
                        fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
                        lopper.log._warning( f"{exc_type} {fname} {exc_tb.tb_lineno}" )
                        if self.werror:
                            sys.exit(1)
            else:
                lopper.log._info( f"no compatible output assist found, skipping" )
                if self.werror:
                    lopper.log._error( f"werror is enabled, and no compatible output assist found, exiting" )
                    sys.exit(2)

    def find_any_matching_assists(self, input_files, local_search_paths=[], xlate_fallback=False):
        """Locates assist files that match any of the given input files (BitBake-style)

        This routine searches both system directories (lopper_directory, lopper_directory +
        "assists", lopper_directory + "lops") and passed paths (local_search_paths) to
        locate all assist files (.lop/.dts) that match any of the provided input files.
        Assist files can match either exactly (on full filename including extension) or via
        a BitBake-style wildcard: an assist named 'foo%.lop' will match any input file
        whose base name (with extension) begins with 'foo'.

        Args:
           input_files (list of strings): input file names (can include paths)
           local_search_paths (list of strings, optional): list of directories to search
                                                           in addition to system dirs
           xlate_fallback (bool, optional): if True, fall back to legacy lop-xlate-{ext}.dts
                                            pattern when no BitBake match is found. This
                                            maintains backward compatibility with -x/--xlate.

        Returns:
           list of strings: Sorted list of unique absolute paths to assist files
                            that match any of the input files, or an empty list if none found
        """
        search_paths = (
            self.load_paths +
            [lopper_directory] +
            [os.path.join(lopper_directory, "assists")] +
            [os.path.join(lopper_directory, "lops")] +
            local_search_paths
        )

        # Gather all assist files (.lop or .dts)
        assists = []
        for apath in search_paths:
            if not os.path.isdir(apath):
                continue
            for fname in os.listdir(apath):
                if fname.endswith('.dts') or fname.endswith('.lop'):
                    assists.append(os.path.join(apath, fname))

        found = set()
        for file_path in input_files:
            base = os.path.basename(file_path)
            file_matched = False
            for assist_path in assists:
                assist_fname = os.path.basename(assist_path)
                # Exact match
                if assist_fname == base:
                    found.add(assist_path)
                    file_matched = True
                # Wildcard match: BitBake style with prefix and suffix support
                # Examples:
                #   domain%.lop      -> matches files starting with "domain"
                #   %.yaml.lop       -> matches files ending with ".yaml"
                #   domain%.yaml.lop -> matches files starting with "domain" AND ending with ".yaml"
                elif '%' in assist_fname:
                    idx = assist_fname.index('%')
                    prefix = assist_fname[:idx]

                    # Remove .lop or .dts extension to get the suffix pattern
                    assist_base, _ = os.path.splitext(assist_fname)  # e.g., "domain%.yaml" or "%.yaml"
                    suffix = assist_base[idx+1:]  # Everything after % (e.g., ".yaml" or "")

                    # Check prefix and suffix
                    matches_prefix = base.startswith(prefix) if prefix else True
                    matches_suffix = base.endswith(suffix) if suffix else True

                    if matches_prefix and matches_suffix:
                        found.add(assist_path)
                        file_matched = True

            # Fallback: check for legacy lop-xlate-{extension}.dts pattern
            # This maintains backward compatibility with -x/--xlate usage
            if xlate_fallback and not file_matched:
                _, ext = os.path.splitext(base)
                if ext:
                    ext_name = ext.lstrip('.')  # ".yaml" -> "yaml"
                    legacy_lop = f"lop-xlate-{ext_name}.dts"
                    for assist_path in assists:
                        if os.path.basename(assist_path) == legacy_lop:
                            found.add(assist_path)

        return sorted(found)  # sorted for determinism

    def input_find(self, input_file_name, auto_extensions = [], local_search_paths = []):
        """Locates a python module that matches assist_name

        This routine searches both system (lopper_directory, lopper_directory +
        "assists", and passed paths (local_load_paths) to locate a matching
        python implementation.

        Args:
           input_file_name (string): name of the input file to locate
           auto_extensions (list of strings, optional): list of extensions to attempt
                                                        when searching for an input
                                                        (currently unused)
           local_search_paths (list of strings, optional): list of directories to search
                                                           in addition to system dirs

        Returns:
           String: absolute path to the located input file or None if not found
        """

        # NOTE: this routine will be combined with assist_find() in the future
        lopper.log._info( f"input_find: {input_file_name}\n"
                          f"        system search: {self.load_paths}\n"
                          f"        local search: {local_search_paths}" )

        input_file = Path( input_file_name )
        try:
            input_file_abs = resolve_path_with_drive_preservation(input_file)
            if not input_file_abs:
                raise FileNotFoundError( f"Unable to find file: {input_file}" )
        except (FileNotFoundError,NotADirectoryError):
            # check the path from which lopper is running, that
            # directory + lops, and paths specified on the command line
            input_file_abs = ""
            search_paths =  self.load_paths + [ lopper_directory ] + [ lopper_directory + "/assists/" ] + \
                            [ lopper_directory + "/lops/" ] + local_search_paths
            for s in search_paths:
                # the first found file is the one we return
                if input_file_abs:
                    continue

                lopper.log._debug( f"input_find: checking directory: {s} for: {input_file}")
                input_file_test = Path( s + "/" + input_file.as_posix() )
                try:
                    input_file_abs = resolve_path_with_drive_preservation(input_file_test)

                    if not input_file_abs:
                        raise FileNotFoundError( f"Unable to find file: {input_file}" )
                    else:
                        lopper.log._debug( f"input_find: found {input_file_abs}" )
                except (FileNotFoundError,NotADirectoryError):
                    input_file_abs = ""

                if not input_file_abs:
                    # try our extensions
                    for extension in auto_extensions:
                        # try it with a the extension
                        input_file_with_ext = Path( s + "/" + input_file.as_posix() + extension )
                        try:
                            input_file_abs = resolve_path_with_drive_preservation(input_file_with_ext)
                            if not input_file_abs:
                                raise FileNotFoundError( f"Unable to find input file: {input_file_with_ext}" )
                        except (FileNotFoundError,NotADirectoryError):
                            input_file_abs = ""

            if not input_file_abs:
                lopper.log._error( f"input file {input_file_name} not found" )
                if self.werror:
                    sys.exit(1)
                return None

        return input_file_abs.as_posix()

    def assist_find(self, assist_name, local_load_paths = []):
        """Locates a python module that matches assist_name

        This routine searches both system (lopper_directory, lopper_directory +
        "assists", and passed paths (local_load_paths) to locate a matching
        python implementation.

        Args:
           assist_name (string): name of the assist to locate
           local_load_paths (list of strings, optional): list of directories to search
                                                         in addition to system dirs

        Returns:
           Path: Path object to the located python module, None on failure

        """
        found_file = self.input_find( assist_name, [ ".py" ], local_load_paths )
        if found_file:
            return Path( found_file )

        return None

    def assists_wrap(self):
        """wrap assists that have been added to the device tree

        Wraps any command line assists that have been added to the system
        device tree. A standard lop format dtb is generated for any found
        assists, such that they will be loaded in the same manner as
        assists passed directly in lop files.

        Note: this is for internal use only

        Args:
           None

        Returns:
           Nothing

        """
        if self.assists:
            lt = LopperTree()

            lt['/']['compatible'] = [ 'system-device-tree-v1' ]
            lt['/']['priority'] = [ 1 ]

            ln = LopperNode()
            ln.name = "lops"

            assist_count = 0
            for a in set(self.assists):
                lop_name = f"lop_{assist_count}"

                lop_node = LopperNode()
                lop_node.name = lop_name
                lop_node['compatible'] = [ 'system-device-tree-v1,lop,load' ]
                lop_node['load'] = [ a.file ]

                ln = ln + lop_node

                lopper.log._debug( f"generated load lop for assist {a}" )

                assist_count = assist_count + 1

            lt = lt + ln

            lop = LopperFile( 'commandline' )
            lop.dts = ""
            lop.dtb = ""
            lop.fdt = None
            lop.tree = lt

            self.lops.insert( 0, lop )

    def domain_spec(self, tgt_domain, tgt_domain_id = "openamp,domain-v1"):
        """generate a lop for a command line passed domain

        When a target domain is passed on the command line, we must generate
        a lop dtb for it, so that it can be processed along with other
        operations

        Args:
           tgt_domain (string): path to the node to use as the domain
           tgt_domain_id (string): assist identifier to use for locating a
                                   registered assist.

        Returns:
           Nothing

        """
        # This is called from the command line. We need to generate a lop
        # device tree with:
        #
        # lop_0 {
        #     compatible = "system-device-tree-v1,lop,assist-v1";
        #     node = "/chosen/openamp_r5";
        #     id = "openamp,domain-v1";
        # };
        # and then inject it into self.lops to run first

        lt = LopperTree()

        lt['/']['compatible'] = [ 'system-device-tree-v1' ]
        lt['/']['priority'] = [ 3 ]

        ln = LopperNode()
        ln.name = "lops"

        mod_count = 0
        lop_name = f"lop_{mod_count}"

        lop_node = LopperNode()
        lop_node.name = lop_name
        lop_node['compatible'] = [ 'system-device-tree-v1,lop,assist-v1' ]
        lop_node['id'] = [ tgt_domain_id ]
        lop_node['node'] = target_domain

        ln = ln + lop_node
        lt = lt + ln

        lop = LopperFile( 'commandline' )
        lop.dts = ""
        lop.dtb = ""
        lop.fdt = None
        lop.tree = lt

        self.lops.insert( 0, lop )

    def find_compatible_assist( self, cb_node = None, cb_id = "", mask = "" ):
        """Finds a registered assist that is compatible with a given ID

        Searches the registered assists for one that is compatible with an ID.

        The is_compat() routine is called for each registered module. If an
        assist is capabable of handling a given ID, it returns True and
        associated actions can then be taken.

        I addition to an ID string, a mask can optionally be provided to this
        routine. Any assists that have registered a mask, will have that
        checked, before calling the is_compat() routine. This allows assists to
        be generically registered, but filtered by the caller rather than only
        their is_compat() routines.

        Args:
            cb_node (int,optional): node offset to be tested. Default is 0 (root)
            cb_id (string,optional): ID to be tested for compatibility. Default is ""
            mask (string,optional): caller mask for filtering nodes. Default is ""

        Returns:
            function reference: the callback routine, or "", if no compatible routine found

        """
        # default for cb_node is "start at root (0)"
        cb_func = []
        if self.assists:
            for a in self.assists:
                if a.module:
                    # if the passed id is empty, check to see if the assist has
                    # one as part of its data
                    if not cb_id:
                        try:
                            cb_id = a.properties['id']
                        except:
                            cb_id = ""

                    # if a non zero mask was passed, and the module has a mask, make
                    # sure they match before even considering it.
                    mask_ok = True
                    try:
                        assist_mask = a.properties['mask']
                    except:
                        assist_mask = ""

                    if mask and assist_mask:
                        mask_ok = False
                        # TODO: could be a regex
                        if mask == assist_mask:
                            mask_ok = True

                    if mask_ok:
                        cb_f = a.module.is_compat( cb_node, cb_id )

                    if cb_f:
                        cb_func.append( cb_f )
                        # we could double check that the function exists with this call:
                        #    func = getattr( m, cbname )
                        # but for now, we don't
                else:
                    lopper.log._warning( f"a configured assist has no module loaded" )
        else:
            lopper.log._info( f"no modules loaded, no compat search is possible" )

        return cb_func

    def exec_lop( self, lop_node, lops_tree, options = None ):
        """Executes a a lopper operation (lop)

        Runs a lopper operation against the system device tree.

        Details of the lop are in the lops_fdt, with extra parameters and lop
        specific information from the caller being passed in the options
        variable.

        Args:
            lops_fdt (FDT): lopper operation flattened device tree
            lop_node_number (int): node number for the operation in lops_fdt
            options (dictionary,optional): lop specific options passed from the caller

        Returns:
            boolean

        """

        # TODO: stop using this and go to the searching in the lops processing loop.
        lop_type = lop_node['compatible'].value[0]
        # TODO: lop_args is really a "subtype"
        try:
            lop_args = lop_node['compatible'].value[1]
        except:
            lop_args = ""

        lopper.log._debug( f"executing lop: {lop_type}" )

        if re.search( r".*,exec.*$", lop_type ):
            lopper.log._debug( f"code exec jump" )
            try:
                try:
                    node_spec = lop_node['node'].value[0]
                except:
                    if self.tree.__selected__:
                        node_spec = self.tree.__selected__[0]
                    else:
                        node_spec = ""

                if not options:
                    options = {}

                try:
                    options_spec = lop_node['options'].value
                except:
                    options_spec = ""

                if options_spec:
                    for o in options_spec:
                        opt_key,opt_val = o.split(":")
                        if opt_key:
                            options[opt_key] = opt_val

                exec_tgt = lop_node['exec'].value[0]
                target_node = lops_tree.pnode( exec_tgt )

                lopper.log._debug( f"exec phandle: {hex(exec_tgt)} target: {target_node}", lop_node )

                if target_node:
                    try:
                        if node_spec:
                            options['start_node'] = node_spec

                        ret = self.exec_lop( target_node, lops_tree, options )
                    except Exception as e:
                        lopper.log._warning( f"exec block caused exception: {e}" )
                        ret = False

                    return ret
                else:
                    return False

            except Exception as e:
                lopper.log._warning( f"exec lop exception: {e}" )
                return False

        if re.search( r".*,print.*$", lop_type ):
            print_props = lop_node.props('print.*')
            for print_prop in print_props:
                for line in print_prop.value:
                    if type(line) == str:
                        print( line )
                    else:
                        # is it a phandle?
                        node = self.tree.pnode(line)
                        if node:
                            print( f"{node} {{" )
                            for p in node:
                                print( f"    {p}" )
                            print( "}" )

            return True

        if re.search( r".*,select.*$", lop_type ):
            select_props = lop_node.props( 'select.*' )

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found" )
                    sys.exit(1)
            except:
                tree = self.tree

            #
            # to do an "or" condition
            #    select_1 = "/path/or/regex/to/nodes:prop:val";
            #    select_2 = "/path/or/2nd/regex:prop2:val2";
            #
            # to do an "and" condition:
            #    select_1 = "/path/or/regex/to/nodes:prop:val";
            #    select_2 = ":prop2:val2";
            #
            selected_nodes = []
            selected_nodes_possible = []
            for sel in select_props:
                if sel.value == ['']:
                    lopper.log._debug( f"clearing selected nodes" )
                    tree.__selected__ = []
                else:
                    # if different node regex + properties are listed in the same
                    # select = "foo","bar","blah", they are always AND conditions.
                    for s in sel.value:
                        lopper.log._debug( f"running node selection: {s} ({selected_nodes_possible})" )
                        try:
                            node_regex, prop, prop_val = s.split(":")
                        except:
                            node_regex = s
                            prop = ""
                            prop_val = ""

                        if node_regex:
                            if node_regex.startswith( "/" ):
                                if selected_nodes_possible:
                                    selected_nodes_possible = selected_nodes_possible + tree.nodes( node_regex )
                                else:
                                    selected_nodes_possible = tree.nodes( node_regex )
                            else:
                                # search with it as a label
                                if selected_nodes_possible:
                                    selected_nodes_possible = selected_nodes_possible + tree.lnodes( node_regex )
                                else:
                                    selected_nodes_possible = tree.lnodes( node_regex )
                        else:
                            # if the node_regex is empty, we operate on previously
                            # selected nodes.
                            if selected_nodes:
                                selected_nodes_possible = selected_nodes
                            else:
                                selected_nodes_possible = tree.__selected__

                            lopper.log._debug( "selected potential nodes:", level=lopper.log.TRACE )
                            for n in selected_nodes_possible:
                                lopper.log._debug( f"       {n}", level=lopper.log.TRACE )

                        if prop and prop_val:
                            invert_result = False
                            if re.search( r"\!", prop_val ):
                                invert_result = True
                                prop_val = re.sub( r'^\!', '', prop_val )
                                lopper.log._debug( f"select: inverting result" )

                            # in case this is a formatted list, ask lopper to convert
                            prop_val = Lopper.property_convert( prop_val )

                            # construct a test prop, so we can use the internal compare
                            # Note:
                            #   - This property is not assigned to a node, or a tree
                            #   - Which means phandles can't be resolved to nodes, since
                            #     that requires a node and a tree
                            #   - Comparisons of phandles don't require nodes, since we
                            #     can look them up by label
                            #   - We are relying on the property comparision, which uses
                            #     phandle_map() to tag phandles as "#invalid" strings
                            #     which we identify as non-zero and do the lookup.
                            #   - If that condition changes in the future, we could
                            #     assign the property to the tree and a node, and then
                            #     remove it after, since that would allow phandle resolution
                            test_prop = LopperProp( prop, -1, None, prop_val )
                            test_prop.ptype = test_prop.property_type_guess( True )

                            # we need this list(), since the removes below will yank items out of
                            # our iterator if we aren't careful
                            for sl in list(selected_nodes_possible):
                                try:
                                    sl_prop = sl[prop]
                                except Exception as e:
                                    sl_prop = None
                                    are_they_equal = False

                                if sl_prop:
                                    if self.verbose > 2:
                                        test_prop.__dbg__ = self.verbose

                                    are_they_equal = test_prop.compare( sl_prop )
                                    if invert_result:
                                        are_they_equal = not are_they_equal

                                    if are_they_equal:
                                        if not sl in selected_nodes:
                                            selected_nodes.append( sl )
                                    else:
                                        # no match, you are out! (only if this is an AND operation though, which
                                        # is indicated by the lack of a node regex)
                                        if not node_regex:
                                            if sl in selected_nodes:
                                                selected_nodes.remove( sl )
                                else:
                                    # no prop, you are out! (only if this is an AND operation though, which
                                    # is indicated by the lack of a node regex)
                                    if not node_regex:
                                        if sl in selected_nodes:
                                            selected_nodes.remove( sl )

                        if prop and not prop_val:
                            # an empty property value means we are testing if the property exists

                            # if the property name is "!<property>" and the val is empty, then we
                            # are testing if it doesn't exist.

                            prop_exists_test = True
                            if re.search( r"\!", prop ):
                                prop_exists_test = False

                            # remove any leading '!' from the name.
                            prop = re.sub( r'^\!', '', prop )

                            for sl in list(selected_nodes_possible):
                                try:
                                    sl_prop = sl[prop]
                                except Exception as e:
                                    sl_prop = None

                                if prop_exists_test:
                                    if sl_prop != None:
                                        if not sl in selected_nodes:
                                            selected_nodes.append( sl )
                                    else:
                                        if sl in selected_nodes:
                                            selected_nodes.remove( sl )
                                else:
                                    # we are looking for the *lack* of a property
                                    if sl_prop:
                                        if sl in selected_nodes:
                                            selected_nodes.remove( sl )
                                    else:
                                        if not sl in selected_nodes:
                                            selected_nodes.append( sl )

                        if not prop and not prop_val:
                            selected_nodes = selected_nodes_possible


                    lopper.log._debug( "select pass done: selected nodes:", level=lopper.log.TRACE )
                    for n in selected_nodes:
                        lopper.log._debug( f"    {n}", level=lopper.log.TRACE )

                    # these are now our possible selected nodes for any follow
                    # up "or" conditions
                    selected_nodes_possible = selected_nodes

            # update the tree selection with our results
            tree.__selected__ = selected_nodes

            if tree.__selected__:
                return True

            return False

        if re.search( r".*,meta.*$", lop_type ):
            if re.search( r"phandle-desc", lop_args ):
                lopper.log._debug( f"processing phandle meta data {type(Lopper)}")

                # grab all the defaults
                Lopper.phandle_possible_prop_dict = Lopper.phandle_possible_properties()
                try:
                    del Lopper.phandle_possible_prop_dict["DEFAULT"]
                except:
                    pass

                # now override, remove, extend
                for p in lop_node:
                    # we skip compatible, since that is actually the compatibility value
                    # of the node, not a meta data entry. Everything else is though
                    if p.name != "compatible":
                        if re.search( r'^reset$', p.name ):
                            lopper.log._debug( f"resetting phandle table" )
                            Lopper.phandle_possible_prop_dict = OrderedDict()
                        elif re.search( r'^lopper-comment.*', p.name ):
                            # skip
                            pass
                        elif re.search( r'^\-.*', p.name ):
                            # delete
                            p.name = re.sub( r'^\-', '', p.name )
                            try:
                                del Lopper.phandle_possible_prop_dict[p.name]
                            except:
                                pass
                        else:
                            Lopper.phandle_possible_prop_dict[p.name] = [ p.value[0] ]

            return True

        if re.search( r".*,output$", lop_type ):
            try:
                output_file_name = lop_node['outfile'].value[0]
            except:
                lopper.log._error( f"cannot get output file name from lop" )
                sys.exit(1)

            lopper.log._debug( f"outfile is: {output_file_name}" )

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found" )
                    sys.exit(1)
            except:
                tree = self.tree


            output_nodes = []
            try:
                output_regex = lop_node['nodes'].value
            except:
                output_regex = []

            if not output_regex:
                if tree.__selected__:
                    output_nodes = tree.__selected__

            if not output_regex and not output_nodes:
                return False

            lopper.log._debug( f"output regex: {output_regex}" )

            output_tree = None
            if output_regex:
                output_nodes = []
                # select some nodes!
                if "*" in output_regex:
                    output_tree = LopperTree( True )
                    output_tree.load( tree.export() )
                    output_tree.strict = not self.permissive
                else:
                    # we can gather the output nodes and unify with the selected
                    # copy below.
                    for regex in output_regex:
                        split_node = regex.split(":")
                        o_node_regex = split_node[0]
                        o_prop_name = ""
                        o_prop_val = ""
                        if len(split_node) > 1:
                            o_prop_name = split_node[1]
                            if len(split_node) > 2:
                                o_prop_val = split_node[2]

                        # Note: we may want to switch this around, and copy the old tree and
                        #       delete nodes. This will be important if we run into some
                        #       strangely formatted ones that we can't copy.

                        try:
                            # if there's no / anywhere in the regex, then it is just
                            # a node name, and we need to wrap it in a regex. This is
                            # for compatibility with when just node names were allowed
                            c = re.findall( r'/', o_node_regex )
                            if not c:
                                o_node_regex = ".*" + o_node_regex

                            o_nodes = tree.nodes(o_node_regex)
                            if not o_nodes:
                                # was it a label ?
                                label_nodes = []
                                try:
                                    o_nodes = tree.lnodes(o_node_regex)
                                except Exception as e:
                                    pass

                            for o in o_nodes:
                                lopper.log._debug( f"output lop, checking node: {o.abs_path}" )

                                # we test for a property in the node if it was defined
                                if o_prop_name:
                                    p = tree[o].propval(o_prop_name)
                                    if o_prop_val:
                                        if p:
                                            if o_prop_val in p:
                                                if not o in output_nodes:
                                                    output_nodes.append( o )
                                else:
                                    if not o in output_nodes:
                                        output_nodes.append( o )

                        except Exception as e:
                            lopper.log._warning( f"exception caught during output processing: {e}" )

                if output_regex:
                    lopper.log._debug( "output lop, final nodes:", level=lopper.log.TRACE )
                    for oo in output_nodes:
                        lopper.log._debug( f"       {oo.abs_path}", level=lopper.log.TRACE )

                if not output_tree and output_nodes:
                    output_tree = LopperTreePrinter()
                    output_tree.strict = not self.permissive
                    output_tree.__dbg__ = self.verbose
                    for on in output_nodes:
                        # make a deep copy of the selected node
                        new_node = on()
                        new_node.__dbg__ = self.verbose
                        # and assign it to our tree
                        # if the performance of this becomes a problem, we can use
                        # direct calls to Lopper.node_copy_from_path()
                        output_tree + new_node

            if not self.dryrun:
                if output_tree:
                    output_file_full = self.outdir + "/" + output_file_name

                    if self.use_libfdt:
                        # create a FDT
                        dct = output_tree.export()
                        out_fdt = Lopper.fdt()
                        Lopper.sync( out_fdt, dct )

                    # we should consider checking the type, and not doing the export
                    # if going to dts, since that is already easily done with the tree.
                    self.write( output_tree, output_file_full, True, self.enhanced )
            else:
                lopper.log._info( f"dryrun detected, not writing output file {output_file_name}" )

            return True
        if re.search( r".*,tree$", lop_type ):
            # TODO: consolidate this with the output lop
            try:
                tree_name = lop_node['tree'].value[0]
            except:
                lopper.log._error( f"tree lop: cannot get tree name from lop" )
                sys.exit(1)

            lopper.log._debug( f"tree lop: tree is: {tree_name}" )

            tree_nodes = []
            try:
                tree_regex = lop_node['nodes'].value
            except:
                tree_regex = []

            if not tree_regex:
                if self.tree.__selected__:
                    tree_nodes = self.tree.__selected__

            if not tree_regex and not tree_nodes:
                lopper.log._warning( f"tree lop: no nodes or regex proviced for tree, returning" )
                return False

            new_tree = None
            if tree_regex:
                tree_nodes = []
                # select some nodes!
                if "*" in tree_regex:
                    new_tree = LopperTree( True )
                    new_tree.strict = False
                    new_tree.load( Lopper.export( self.FDT ) )
                    new_tree.resolve()
                    new_tree.strict = not self.permissive
                else:
                    # we can gather the tree nodes and unify with the selected
                    # copy below.
                    for regex in tree_regex:

                        split_node = regex.split(":")
                        o_node_regex = split_node[0]
                        o_prop_name = ""
                        o_prop_val = ""
                        if len(split_node) > 1:
                            o_prop_name = split_node[1]
                            if len(split_node) > 2:
                                o_prop_val = split_node[2]

                        # Note: we may want to switch this around, and copy the old tree and
                        #       delete nodes. This will be important if we run into some
                        #       strangely formatted ones that we can't copy.

                        try:
                            # if there's no / anywhere in the regex, then it is just
                            # a node name, and we need to wrap it in a regex. This is
                            # for compatibility with when just node names were allowed
                            c = re.findall( r'/', o_node_regex )
                            if not c:
                                o_node_regex = ".*" + o_node_regex

                            o_nodes = self.tree.nodes(o_node_regex)
                            if not o_nodes:
                                # was it a label ?
                                label_nodes = []
                                try:
                                    o_nodes = self.tree.lnodes(o_node_regex)
                                except Exception as e:
                                    pass

                            for o in o_nodes:
                                # we test for a property in the node if it was defined
                                if o_prop_name:
                                    p = self.tree[o].propval(o_prop_name)
                                    if o_prop_val:
                                        if p:
                                            if o_prop_val in p:
                                                if not o in tree_nodes:
                                                    tree_nodes.append( o )
                                else:
                                    if not o in tree_nodes:
                                        tree_nodes.append( o )

                        except Exception as e:
                            lopper.log._warning( f"exception caught during tree processing: {e}" )

                if not new_tree and tree_nodes:
                    new_tree = LopperTreePrinter()
                    new_tree.strict = not self.permissive
                    new_tree.__dbg__ = self.verbose
                    for on in tree_nodes:
                        # make a deep copy of the selected node
                        new_node = on()
                        new_node.__dbg__ = self.verbose
                        # and assign it to our tree
                        # if the performance of this becomes a problem, we can use
                        # direct calls to Lopper.node_copy_from_path()
                        new_tree + new_node

            if new_tree:
                self.subtrees[tree_name] = new_tree
            else:
                lopper.log._error( f"no tree created, exiting" )
                sys.exit(1)

            return True

        if re.search( r".*,assist-v1$", lop_type ):
            # also note: this assist may change from being called as
            # part of the lop loop, to something that is instead
            # called by walking the entire device tree, looking for
            # matching nodes and making assists at that moment.
            #
            # but that sort of node walking, will invoke the assists
            # out of order with other lopper operations, so it isn't
            # particularly feasible or desireable.
            #
            try:
                cb_tgt_node_name = lop_node['node'].value[0]
            except Exception as e:
                lopper.log._error( f"cannot find target node for assist {e}" )
                sys.exit(1)

            cb_outdir = self.outdir
            try:
                cb = lop_node.propval('assist')[0]
                cb_id = lop_node.propval('id')[0]
                cb_opts = lop_node.propval('options')[0]
                cb_opts = cb_opts.lstrip()
                if cb_opts:
                    cb_opts = cb_opts.split( ' ' )
                else:
                    cb_opts = []
                if lop_node.propval('outdir') != ['']:
                    cb_outdir = lop_node.propval('outdir')[0]
            except Exception as e:
                lopper.log._error( f"callback options are missing: {e}" )
                sys.exit(1)

            try:
                cb_node = self.tree.nodes(cb_tgt_node_name )[0]
            except:
                cb_node = None

            if cb_tgt_node_name != "/" and not cb_node:
                if self.werror:
                    lopper.log._error( f"cannot find assist target node in tree" )
                    sys.exit(1)
                else:
                    return False

            lopper.log._info( "assist lop detected" )
            if cb:
                lopper.log._debug( f"        cb: {cb}" )
            lopper.log._debug( f"        id: {cb_id} opts: {cb_opts}" )

            cb_funcs = self.find_compatible_assist( cb_node, cb_id )
            if cb_funcs:
                for cb_func in cb_funcs:
                    try:
                        if not cb_func( cb_node, self, { 'verbose' : self.verbose, 'outdir' : cb_outdir, 'args': cb_opts } ):
                            lopper.log._warning( f"the assist returned false, check for errors ..." )
                    except Exception as e:
                        lopper.log._warning( f"assist %{cb_func} failed: {e}" )
                        exc_type, exc_obj, exc_tb = sys.exc_info()
                        fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
                        lopper.log._warning( f"{exc_type} {fname} {exc_tb.tb_lineno}" )
                        # exit if warnings are treated as errors
                        if self.werror:
                            sys.exit(1)

                        return False
            else:
                lopper.log._info( f"no compatible assist found, skipping: {cb_tgt_node_name}{cb}")
                return False

            return True

        if re.search( r".*,lop,load$", lop_type ):
            prop_id = ""
            prop_extension = ""

            try:
                load_prop = lop_node['load'].value[0]
            except:
                load_prop = ""

            if load_prop:
                # for submodule loading
                for p in self.load_paths:
                    if p not in sys.path:
                        sys.path.append( p )

                lopper.log._info( f"loading module {load_prop}" )

                mod_file = self.assist_find( load_prop, self.load_paths )
                if not mod_file:
                    lopper.log._error( f"unable to find assist ({load_prop })" )
                    sys.exit(1)

                mod_file_abs = mod_file.resolve()

                # append the directory of the located module onto the search
                # path. This is needed if that module imports something from
                # its own directory
                sys.path.append( lopper_directory )
                sys.path.append( lopper_directory + "/assists" )
                sys.path.append( str(mod_file_abs.parent) )
                try:
                    imported_module = SourceFileLoader( mod_file.name, str(mod_file_abs) ).load_module()
                except Exception as e:
                    lopper.log._error( f"could not load assist: {mod_file_abs}: {e}" )
                    sys.exit(1)

                assist_properties = {}
                try:
                    props = lop_node['props'].value
                except:
                    # does the module have a "props" routine for extra querying ?
                    try:
                        props = imported_module.props()
                    except:
                        props = []

                for p in props:
                    # TODO: we can generate and evaluate these generically, right now, this
                    #       is ok as a proof of concept only
                    if p == "file_ext":
                        try:
                            prop_extension = lop_node['file_ext'].value[0]
                        except:
                            try:
                                prop_extension = imported_module.file_ext()
                            except:
                                prop_extension = ""

                        assist_properties['mask'] = prop_extension

                    if p == "id":
                        try:
                            prop_id = lop_node['id'].value[0]
                        except:
                            try:
                                prop_id = imported_module.id()
                            except:
                                prop_id = ""

                        assist_properties['id'] = prop_id

                # TODO: move this "assist already available" check into a function
                already_loaded = False
                if self.assists:
                    for a in self.assists:
                        try:
                            if Path(a.file).resolve() == mod_file.resolve():
                                already_loaded = True
                                a.module = imported_module
                                a.properties = assist_properties
                        except:
                            pass
                if not already_loaded:
                    lopper.log._info( f"loading assist with properties ({prop_extension}, {prop_id})", prop_extension )
                    self.assists.append( LopperAssist( mod_file.name, imported_module, assist_properties ) )

            return True

        if re.search( r".*,lop,add$", lop_type ):
            lopper.log._info( f"node add lop" )

            try:
                src_node_name = lop_node['node_src'].value[0]
            except:
                lopper.log._error( f"node add detected, but no node name found" )
                sys.exit(1)

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found", True )
            except:
                tree = self.tree


            lops_node_path = lop_node.abs_path
            src_node_path = lops_node_path + "/" + src_node_name

            try:
                dest_node_path = lop_node["node_dest"].value[0]
            except:
                dest_node_path = "/" + src_node_name


            lopper.log._info( f"add node name: {src_node_path} node path: {dest_node_path}" )


            if tree:
                src_node = lops_tree[src_node_path]

                # copy the source node
                dst_node = src_node()
                # adjust the path to where it will land
                dst_node.abs_path = dest_node_path

                # add it to the tree, and this will adjust the children appropriately
                tree + dst_node
            else:
                lopper.log._error( f"unable to copy node: {src_node_name}", True )

            return True

        if re.search( r".*,lop,conditional.*$", lop_type ):
            lopper.log._info( f"conditional lop found" )

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found", True)
            except:
                tree = self.tree

            this_lop_subnodes = lop_node.subnodes()
            # the "cond_root" property of the lop node is the name of a node
            # under the same lop node that is the start of the conditional node
            # chain. If one wasn't provided, we start at '/'
            try:
                root = lop_node["cond_root"].value[0]
            except:
                root = "/"

            try:
                conditional_start = lops_tree[lop_node.abs_path + "/" + root]
            except:
                lopper.log._info( f"conditional node {lop_node.abs_path + '/' + root} not found, returning" )
                return False

            try:
                cond_select = lop_node["cond_select"]
            except:
                cond_select = None

            # the subnodes of the conditional lop represent the set of conditions
            # to use. The deepest node is what we'll be comparing
            cond_nodes = conditional_start.subnodes()
            # get the last node
            cond_last_node = cond_nodes[-1]

            if cond_select:
                cond_path = cond_select.value[0]
            else:
                # drop the path to the this conditional lop from the full path of
                # the last node in the chain. That's the path we'll look for in the
                # system device tree.
                cond_path = re.sub( lop_node.abs_path, "", cond_last_node.abs_path)

            sdt_tgt_nodes = tree.nodes(cond_path)
            if not sdt_tgt_nodes:
                lopper.log._debug( f"no target nodes found at: {cond_path}, returning" )
                return False

            tgt_matches = []
            tgt_false_matches = []
            # iterate the properties in the final node of the conditional tree,
            # these are the conditions that we are checking.
            for cond_prop in cond_last_node:
                cond_prop_name = cond_prop.name
                invert_check = ""
                # remove __not__ from the end of a property name, that is an
                # indication for us only, and won't be in the SDT node
                if cond_prop.name.endswith( "__not__" ):
                    cond_prop_name = re.sub( r"__not__$", "", cond_prop.name )
                    invert_check = "not"

                lopper.log._debug( f"conditional property: {cond_prop_name} tgt_nodes: {sdt_tgt_nodes}" )

                for tgt_node in sdt_tgt_nodes:
                    # is the property present in the target node ?
                    try:
                        tgt_node_prop = tgt_node[cond_prop_name]
                    except:
                        tgt_node_prop = None

                    # no need to compare if the target node doesn't have the property
                    if tgt_node_prop:
                        check_val = cond_prop.compare( tgt_node_prop )

                        # if there was an inversion in the name, flip the result
                        check_val_final = eval( f"{invert_check} {check_val}")
                        lopper.log._debug( f"   ({tgt_node.abs_path}:{tgt_node_prop.value[0]}) condition check final value: {invert_check} {check_val} was {check_val_final}")
                        if check_val_final:
                            # if not already in the list, we need to add the target node
                            if not tgt_node in tgt_matches:
                                tgt_matches.append(tgt_node)
                        else:
                            # if subsequent props are not True, then we need to yank out
                            # the node from our match list
                            if tgt_node in tgt_matches:
                                tgt_matches.remove(tgt_node)
                            # and add it to the false matches list
                            if not tgt_node in tgt_false_matches:
                                tgt_false_matches.append(tgt_node)
                    else:
                        # if it doesn't have it, that has to be a false!
                        lopper.log._debug( f"system device tree node '{tgt_node}' does not have property '{cond_prop_name}'" )

                        # if subsequent props are not True, then we need to yank out
                        # the node from our match list
                        if tgt_node in tgt_matches:
                            tgt_matches.remove(tgt_node)
                        # and add it to the false matches list
                        if not tgt_node in tgt_false_matches:
                            tgt_false_matches.append(tgt_node)

            # loop over the true matches, executing their operations, if one of them returns
            # false, we stop the loop
            for tgt_match in tgt_matches:
                try:
                    # we look through all the subnodes of this lopper operation. If any of them
                    # start with "true", it is a nested lop that we will execute
                    for n in this_lop_subnodes:
                        if n.name.startswith( "true" ):
                            lopper.log._debug( f"true subnode found with lop:{n['compatible'].value[0]}" )
                            try:
                                # run the lop, passing the target node as an option (the lop may
                                # or may not use it)
                                ret = self.exec_lop( n, lops_tree, { 'start_node' : tgt_match.abs_path } )
                            except Exception as e:
                                lopper.log._warning( f"true block had an exception: {e}" )
                                ret = False

                            # no more looping if the called lop return False
                            if ret == False:
                                lopper.log._debug( f"code block returned false, stop executing true blocks" )
                                break
                except Exception as e:
                    lopper.log._warning( f"conditional had exception: {e}" )

            # just like the target matches, we iterate any failed matches to see
            # if false blocks were defined.
            for tgt_match in tgt_false_matches:
                # no match, is there a false block ?
                try:
                    for n in this_lop_subnodes:
                        if n.name.startswith( "false" ):
                            lopper.log._debug( f"false subnode found with lop: {n['compatible'].value[0]}" )

                            try:
                                ret = self.exec_lop( n, lops_tree, { 'start_node' : tgt_match.abs_path } )
                            except Exception as e:
                                lopper.log._warning( f"false block had an exception: {e}" )
                                ret = False

                            # if any of the blocks return False, we are done
                            if ret == False:
                                lopper.log._debug( f"code block returned false, stop executing true blocks" )
                                break
                except Exception as e:
                    lopper.log._warning( f"conditional false block had exception: {e}" )

            return ret

        if re.search( r".*,lop,code.*$", lop_type ) or re.search( r".*,lop,xlate.*$", lop_type ):
            # execute a block of python code against a specified start_node
            code = lop_node['code'].value[0]

            if not options:
                options = {}

            try:
                options_spec = lop_node['options'].value
            except:
                options_spec = ""

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found", True )
            except:
                tree = self.tree

            if options_spec:
                for o in options_spec:
                    opt_key,opt_val = o.split(":")
                    if opt_key:
                        options[opt_key] = opt_val

            # arrange for the system device tree's output directory to
            # be visible in the executed code environment
            try:
                output = options['output']
            except:
                options['outdir'] = self.outdir

            # make target_domain available to code lops
            if self.target_domain:
                options['target_domain'] = self.target_domain

            try:
                start_node = options['start_node']
            except:
                # were there selected nodes ? Make them the context, unless overrriden
                # by an explicit start_node property
                if tree.__selected__:
                    start_node = tree.__selected__[0]
                else:
                    start_node = "/"

            try:
                inherit_list = lop_node['inherit'].value[0].replace(" ","").split(",")
            except:
                inherit_list = []

            lopper.log._debug( f"code lop found, node context: {start_node}" )

            if re.search( r".*,lop,xlate.*$", lop_type ):
                inherit_list.append( "lopper_lib" )

                if tree.__selected__:
                    node_list = tree.__selected__
                else:
                    node_list = [ "/" ]

                for n in node_list:
                    ret = tree.exec_cmd( n, code, options, inherit_list, self.load_paths )
                    # who knows what the command did, better sync!
                    tree.sync()
            else:
                ret = tree.exec_cmd( start_node, code, options, inherit_list, self.load_paths )
                # who knows what the command did, better sync!
                tree.sync()

            return ret

        if re.search( r".*,lop,modify$", lop_type ):
            node_name = lop_node.name
            lopper.log._info( f"node {node_name} is a compatible modify lop" )
            try:
                prop = lop_node["modify"].value[0]
            except:
                prop = ""

            try:
                tree_name = lop_node['tree'].value[0]
                try:
                    tree = self.subtrees[tree_name]
                except:
                    lopper.log._error( f"tree name provided ({tree_name}), but not found", True )
            except:
                tree = self.tree

            try:
                flags = lop_node['flags'].value[0]
            except:
                flags = ""

            try:
                nodes_selection = lop_node["nodes"].value[0]
            except:
                nodes_selection = ""
            if prop:
                lopper.log._debug( f"modify property found: {prop}" )

                # format is: "path":"property":"replacement"
                #    - modify to "nothing", is a remove operation
                #    - modify with no property is node operation (rename or remove)
                modify_expr = prop.split(":")
                # combine these into the assigment, once everything has bee tested
                modify_path = modify_expr[0]
                modify_prop = modify_expr[1]
                modify_val = modify_expr[2]

                lopper.log._info( f"modify path: {modify_expr[0]}" )
                lopper.log._info( f"modify prop: {modify_expr[1]}" )
                lopper.log._info( f"modify repl: {modify_expr[2]}" )
                if nodes_selection:
                    lopper.log._info( f"modify regex: {nodes_selection}" )

                # if modify_expr[0] (the nodes) is empty, we use the selected nodes
                # if they are available
                if not modify_path:
                    if not tree.__selected__:
                        lopper.log._warning( f"no nodes supplied to modify, and no nodes are selected" )
                        lop_node.print()
                        return False
                    else:
                        nodes = tree.__selected__
                else:
                    try:
                        nodes = tree.subnodes( tree[modify_path] )
                    except Exception as e:
                        lopper.log._debug( f"modify lop: node issue: {e}" )
                        nodes = []

                if modify_prop:
                    # property operation
                    if not modify_val:
                        lopper.log._info( f"property remove operation detected: {modify_path} {modify_prop}" )

                        try:
                            # TODO: make a special case of the property_modify_below
                            tree.sync()

                            for n in nodes:
                                try:
                                    n.delete( modify_prop )
                                except:
                                    lopper.log._info( f"property {modify_prop} not found in node {n}, and not deleted" )
                                    # no big deal if it doesn't have the property
                                    pass

                            tree.sync()
                        except Exception as e:
                            lopper.log._error( f"unable to remove property {modify_path}/{modify_prop} ({e})", True )
                    else:
                        lopper.log._info( f"property modify operation detected" )

                        # set the tree state to "syncd", so we'll be able to test for changed
                        # state later.
                        tree.sync()

                        # we re-do the nodes fetch here, since there are slight behaviour/return
                        # differences between nodes() (what this has always used), and subnodes()
                        # which is what we do above. We can re-test and reconcile this in the future.
                        if modify_path:
                            nodes = tree.nodes( modify_path )
                        else:
                            nodes = tree.__selected__

                        if not nodes:
                            lopper.log._warning( f"node {modify_path} not found,  property {modify_prop} not modified " )

                        # if the value has a "&", it is a phandle, and we need
                        # to try and look it up.
                        if re.search( r'&', modify_val ):
                            # check to see if the match should be strict
                            strict = False
                            if "strict" in flags:
                                strict = True
                            modify_val = tree.label_to_phandle( modify_val, strict, lops_tree )
                        else:
                            modify_val = Lopper.property_convert( modify_val )

                        for n in nodes:
                            if type( modify_val ) == list:
                                n[modify_prop] = modify_val
                            else:
                                n[modify_prop] = [ modify_val ]

                        tree.sync()
                else:
                    lopper.log._info( f"modify lop, node operation" )

                    # drop the list, since if we are modifying a node, it is just one
                    # target node.
                    try:
                        node = nodes[0]
                    except:
                        node = None

                    if not node:
                        lopper.log._error( f"no nodes found for {modify_path}", True )

                    # node operation
                    # in case /<name>/ was passed as the new name, we need to drop them
                    # since they aren't valid in set_name()
                    if modify_val:
                        modify_source_path = Path(node.abs_path)

                        if modify_val.startswith( "/" ):
                            modify_dest_path = Path( modify_val )
                        else:
                            modify_dest_path = Path( "/" + modify_val )

                        if modify_source_path.parent != modify_dest_path.parent:
                            lopper.log._debug( f"[{tree}] node move: {modify_source_path} -> {modify_dest_path}" )

                            # delete the old node
                            tree.delete( node )

                            # deep copy the node
                            new_dst_node = node()
                            new_dst_node.abs_path = modify_val

                            tree + new_dst_node

                            tree.sync()

                        if modify_source_path.name != modify_dest_path.name:
                            lopper.log._debug( f"[{tree}] node rename: {modify_source_path.name} -> {modify_dest_path.name}" )

                            modify_val = modify_val.replace( '/', '' )
                            try:

                                # is there already a node at the new destination path ?
                                try:
                                    old_node = tree[str(modify_dest_path)]
                                    if old_node:
                                        # we can error, or we'd have to delete the old one, and
                                        # then let the rename happen. But really, you can just
                                        # write a lop that takes care of that before calling such
                                        # a bad rename lop.
                                        lopper.log._debug( f"node exists at rename target: {old_node.abs_path}" )
                                        lopper.log._debug( f"Deleting it, to allow rename to continue" )

                                        tree.delete( old_node )
                                except Exception as e:
                                    # no node at the dest
                                    pass

                                # change the name of the node
                                node.name = modify_val
                                tree.sync()

                            except Exception as e:
                                lopper.log._error( f"cannot rename node '{node.abs_path}' to '{modify_val}' ({e})", True )
                    else:
                        # first we see if the node prefix is an exact match
                        node_to_remove = node

                        if not node_to_remove:
                            lopper.log._warning( f"Cannot find node {node.abs_path} for delete operation"  )
                            if self.werror:
                                sys.exit(1)
                        else:
                            try:
                                tree.delete( node_to_remove )
                                tree.sync()
                            except:
                                lopper.log._warning( f"could not remove node number: {node_to_remove.abs_path}" )

            return True

        # if the lop didn't return, we return false by default
        return False

    def perform_lops(self):
        """Execute all loaded lops

        Iterates and executes all the loaded lopper operations (lops) for the
        System Device tree.

        The lops are processed in priority order (priority specified at the file
        level), and the rules processed in order as they appear in the lop file.

        lopper operations can immediately process the output of the previous
        operation and hence can be stacked to perform complex operations.

        Args:
            None

        Returns:
            Nothing

        """
        # was --target passed on the command line ?
        if self.target_domain:
            # is the target domain in our tree ? If not, don't bother queing it, since
            # it is a lop specification
            try:
                td = self.tree[self.target_domain]
                self.domain_spec(self.target_domain)
            except:
                optional_lops_tree = LopperTree()
                for l in self.lops_optional:
                    try:
                        lop_possible = l.tree.nodes(self.target_domain)
                        for ll in lop_possible:
                            optional_lops_tree + ll
                    except:
                        pass

                # promote any matching optional lops, to lops that will be run
                lop = LopperFile( "" )
                lop.dts = ""
                lop.dtb = ""
                lop.fdt = None
                lop.tree = optional_lops_tree
                self.lops.append( lop )

        # force verbose output if --dryrun was passed
        if self.dryrun:
            self.verbose = 2

        lopper.log._info( f"\'{len(self.lops)}\' lopper operation files will be processed" )

        lops_runqueue = {}
        for pri in range(1,10):
            lops_runqueue[pri] = []

        # iterate the lops, look for priority. If we find those, we'll run then first
        for x in self.lops:
            if x.fdt:
                lops_fdt = x.fdt
                lops_tree = None
            elif x.dtb:
                lops_fdt = Lopper.dt_to_fdt(x.dtb)
                x.dtb = None
                x.fdt = lops_fdt
            elif x.tree:
                lops_fdt = None
                lops_tree = x.tree

            if lops_fdt:
                lops_tree = LopperTree()
                try:
                    dct = Lopper.export( lops_fdt, strict=True )
                except Exception as e:
                    lopper.log._error( f"({x}) {e}", True)

                lops_tree.load( dct )

                x.tree = lops_tree

            if not lops_tree:
                lopper.log._error( f"invalid lop file {x}, cannot process", True )

            try:
                ln = lops_tree['/']
                lops_file_priority = ln["priority"].value[0]
            except Exception as e:
                lops_file_priority = 5

            lops_runqueue[lops_file_priority].append(x)

        lopper.log._debug( f"lops runqueue: {lops_runqueue}" )

        lop_results = {}
        # iterate over the lops (by lop-file priority)
        for pri in range(1,10):
            for x in lops_runqueue[pri]:
                fdt_tree = x.tree
                lop_test = re.compile( r'system-device-tree-v1,lop.*')
                lop_cond_test = re.compile( r'.*,lop,conditional.*$' )
                skip_list = []
                for f in fdt_tree:
                    if not any(lop_test.match(i) for i in f.type):
                        continue

                    # past here, we know the node is a lop variant, we need one
                    # more check. Is the parent conditional ? if so, we don't
                    # excute it directly.
                    if any( lop_cond_test.match(i) for i in f.type):
                        skip_list = f.subnodes()
                        # for historical resons, the current node is in the subnodes
                        # yank it out or we'll be skipped!
                        skip_list.remove( f )

                    try:
                        noexec_prop = f['noexec']
                        noexec_val = noexec_prop.value
                        # noexec; or noexec = 1; means skip unconditionally
                        # noexec = 0; means run it (don't skip)
                        # noexec = "expression"; evaluated as Python expression
                        #   - "not __selected__" : skip if nothing is selected
                        #   - "__selected__" : skip if something is selected
                        if noexec_val == [''] or noexec_val == [1] or noexec_val == 1:
                            noexec = True
                        elif noexec_val == [0] or noexec_val == 0:
                            noexec = False
                        elif isinstance(noexec_val, list) and len(noexec_val) == 1 and isinstance(noexec_val[0], str):
                            # Expression evaluation - provide context similar to code-v1 lops
                            expr = noexec_val[0]
                            eval_context = {
                                '__selected__': self.tree.__selected__,
                                'tree': self.tree,
                                'True': True,
                                'False': False,
                                'len': len,
                            }
                            try:
                                noexec = bool(eval(expr, {"__builtins__": {}}, eval_context))
                                lopper.log._debug( f"noexec expression '{expr}' evaluated to {noexec}" )
                            except Exception as e:
                                lopper.log._warning( f"noexec expression '{expr}' failed to evaluate: {e}" )
                                noexec = False
                        else:
                            # Unknown format, treat as truthy (skip)
                            noexec = bool(noexec_val)
                    except:
                        noexec = False

                    try:
                        cond_exec = f['cond'].value[0]
                        tgt_lop = fdt_tree.pnode(cond_exec)
                        cond_exec_value = lop_results[tgt_lop.name]
                        lopper.log._debug( f"conditional {tgt_lop.name} has result {cond_exec_value}" )
                        if cond_exec_value:
                            noexec = False
                        else:
                            noexec = True
                    except Exception as e:
                        pass

                    if noexec or f in skip_list:
                        lopper.log._debug( f"noexec or skip set for:{f.abs_path}" )
                        continue

                    lopper.log._info( f"------> processing lop: {f.abs_path} {f.name}" )

                    # Pass target_domain to lop processing via options
                    lop_options = None
                    if self.target_domain:
                        lop_options = {'target_domain': self.target_domain}

                    result = self.exec_lop( f, fdt_tree, lop_options )
                    lop_results[f.name] = result

                    lopper.log._info( f"------> logged result {result} for lop {f.name}" )


class LopperFile:
    """Internal class to contain the details of a lopper file

    Attributes:
       - dts: the dts source file path for a lop
       - dtb: the compiled dtb file path for a lop
       - fdt: the loaded FDT representation of the dtb

    """
    def __init__(self, lop_file):
        self.dts = lop_file
        self.dtb = ""
        self.fdt = ""
        self.tree = None
