from __future__ import annotations
from typing import TYPE_CHECKING
import itertools
import logging
import sys
from collections import defaultdict
from functools import reduce
import contextlib

import angr
import claripy
import networkx
import pyvex
from archinfo import ArchARM

from angr.analyses import ForwardAnalysis
from angr.utils.graph import GraphUtils
from angr.analyses import AnalysesHub
from angr import BP, BP_BEFORE, BP_AFTER, SIM_PROCEDURES, procedures
from angr import options as o
from angr.codenode import BlockNode
from angr.engines.procedure import ProcedureEngine
from angr.exploration_techniques.loop_seer import LoopSeer
from angr.exploration_techniques.slicecutor import Slicecutor
from angr.exploration_techniques.explorer import Explorer
from angr.exploration_techniques.lengthlimiter import LengthLimiter
from angr.errors import (
    AngrCFGError,
    AngrError,
    AngrSkipJobNotice,
    AngrSyscallError,
    SimError,
    SimValueError,
    SimSolverModeError,
    SimFastPathError,
    SimIRSBError,
    AngrExitError,
    SimEmptyCallStackError,
)
from angr.sim_state import SimState
from angr.state_plugins.callstack import CallStack
from angr.state_plugins.sim_action import SimActionData
from angr.knowledge_plugins.cfg import CFGENode, IndirectJump
from angr.utils.constants import DEFAULT_STATEMENT
from angr.analyses.cdg import CDG
from angr.analyses.ddg import DDG
from angr.analyses.backward_slice import BackwardSlice
from angr.analyses.loopfinder import LoopFinder, Loop
from .cfg_base import CFGBase
from .cfg_job_base import BlockID, CFGJobBase

if TYPE_CHECKING:
    from angr.knowledge_plugins.cfg import CFGNode


l = logging.getLogger(name=__name__)


class CFGJob(CFGJobBase):
    """
    The job class that CFGEmulated uses.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        #
        # local variables used during analysis
        #

        if self.jumpkind is None:
            # load jumpkind from path.state.scratch
            self.jumpkind = "Ijk_Boring" if self.state.history.jumpkind is None else self.state.history.jumpkind

        self.call_stack_suffix = None
        self.current_function = None

        self.cfg_node = None
        self.sim_successors = None
        self.exception_info = None
        self.successor_status = None

        self.extra_info = None

    @property
    def block_id(self):
        if self._block_id is None:
            # generate a new block ID
            self._block_id = CFGEmulated._generate_block_id(
                self.call_stack.stack_suffix(self._context_sensitivity_level), self.addr, self.is_syscall
            )
        return self._block_id

    @property
    def is_syscall(self):
        return self.jumpkind is not None and self.jumpkind.startswith("Ijk_Sys")

    def __hash__(self):
        return hash(self.block_id)


class PendingJob:
    """
    A PendingJob is whatever will be put into our pending_exit list. A pending exit is an entry that created by the
    returning of a call or syscall. It is "pending" since we cannot immediately figure out whether this entry will
    be executed or not. If the corresponding call/syscall intentionally doesn't return, then the pending exit will be
    removed. If the corresponding call/syscall returns, then the pending exit will be removed as well (since a real
    entry is created from the returning and will be analyzed later). If the corresponding call/syscall might
    return, but for some reason (for example, an unsupported instruction is met during the analysis) our analysis
    does not return properly, then the pending exit will be picked up and put into remaining_jobs list.
    """

    def __init__(
        self, caller_func_addr, returning_source, state, src_block_id, src_exit_stmt_idx, src_exit_ins_addr, call_stack
    ):
        """
        :param returning_source:    Address of the callee function. It might be None if address of the callee is not
                                    resolvable.
        :param state:               The state after returning from the callee function. Of course there is no way to get
                                    a precise state without emulating the execution of the callee, but at least we can
                                    properly adjust the stack and registers to imitate the real returned state.
        :param call_stack:          A callstack.
        """

        self.caller_func_addr = caller_func_addr
        self.returning_source = returning_source
        self.state = state
        self.src_block_id = src_block_id
        self.src_exit_stmt_idx = src_exit_stmt_idx
        self.src_exit_ins_addr = src_exit_ins_addr
        self.call_stack = call_stack

    def __repr__(self):
        return "<PendingJob to {}, from function {}>".format(
            self.state.ip, hex(self.returning_source) if self.returning_source is not None else "Unknown"
        )

    def __hash__(self):
        return hash(
            (
                self.caller_func_addr,
                self.returning_source,
                self.src_block_id,
                self.src_exit_stmt_idx,
                self.src_exit_ins_addr,
            )
        )

    def __eq__(self, other):
        if not isinstance(other, PendingJob):
            return False
        return (
            self.caller_func_addr == other.caller_func_addr
            and self.returning_source == other.returning_source
            and self.src_block_id == other.src_block_id
            and self.src_exit_stmt_idx == other.src_exit_stmt_idx
            and self.src_exit_ins_addr == other.src_exit_ins_addr
        )


class CFGEmulated(ForwardAnalysis, CFGBase):  # pylint: disable=abstract-method
    """
    This class represents a control-flow graph.
    """

    tag = "CFGEmulated"

    def __init__(
        self,
        context_sensitivity_level=1,
        start=None,
        avoid_runs=None,
        enable_function_hints=False,
        call_depth=None,
        call_tracing_filter=None,
        initial_state=None,
        starts=None,
        keep_state=False,
        indirect_jump_target_limit=100000,
        resolve_indirect_jumps=True,
        enable_advanced_backward_slicing=False,
        enable_symbolic_back_traversal=False,
        indirect_jump_resolvers=None,
        additional_edges=None,
        no_construct=False,
        normalize=False,
        max_iterations=1,
        address_whitelist=None,
        base_graph=None,
        iropt_level=None,
        max_steps=None,
        state_add_options=None,
        state_remove_options=None,
        model=None,
    ):
        """
        All parameters are optional.

        :param context_sensitivity_level:           The level of context-sensitivity of this CFG (see documentation for
                                                    further details). It ranges from 0 to infinity. Default 1.
        :param avoid_runs:                          A list of runs to avoid.
        :param enable_function_hints:               Whether to use function hints (constants that might be used as exit
                                                    targets) or not.
        :param call_depth:                          How deep in the call stack to trace.
        :param call_tracing_filter:                 Filter to apply on a given path and jumpkind to determine if it
                                                    should be skipped when call_depth is reached.
        :param initial_state:                       An initial state to use to begin analysis.
        :param iterable starts:                     A collection of starting points to begin analysis. It can contain
                                                    the following three different types of entries: an address specified
                                                    as an integer, a 2-tuple that includes an integer address and a
                                                    jumpkind, or a SimState instance. Unsupported entries in starts will
                                                    lead to an AngrCFGError being raised.
        :param keep_state:                          Whether to keep the SimStates for each CFGNode.
        :param resolve_indirect_jumps:              Whether to enable the indirect jump resolvers for resolving indirect
                                                    jumps
        :param enable_advanced_backward_slicing:    Whether to enable an intensive technique for resolving indirect
                                                    jumps
        :param enable_symbolic_back_traversal:      Whether to enable an intensive technique for resolving indirect
                                                    jumps
        :param list indirect_jump_resolvers:        A custom list of indirect jump resolvers. If this list is None or
                                                    empty,
                                                    default indirect jump resolvers specific to this architecture and
                                                    binary types will be loaded.
        :param additional_edges:                    A dict mapping addresses of basic blocks to addresses of
                                                    successors to manually include and analyze forward from.
        :param bool no_construct:                   Skip the construction procedure. Only used in unit-testing.
        :param bool normalize:                      If the CFG as well as all Function graphs should be normalized or
                                                    not.
        :param int max_iterations:                  The maximum number of iterations that each basic block should be
                                                    "executed". 1 by default. Larger numbers of iterations are usually
                                                    required for complex analyses like loop analysis.
        :param iterable address_whitelist:          A list of allowed addresses. Any basic blocks outside of this
                                                    collection of addresses will be ignored.
        :param networkx.DiGraph base_graph:         A basic control flow graph to follow. Each node inside this graph
                                                    must have the following properties: `addr` and `size`. CFG recovery
                                                    will strictly follow nodes and edges shown in the graph, and discard
                                                    any control flow that does not follow an existing edge in the base
                                                    graph. For example, you can pass in a Function local transition
                                                    graph as the base graph, and CFGEmulated will traverse nodes and
                                                    edges and extract useful information.
        :param int iropt_level:                     The optimization level of VEX IR (0, 1, 2). The default level will
                                                    be used if `iropt_level` is None.
        :param int max_steps:                       The maximum number of basic blocks to recover forthe longest path
                                                    from each start before pausing the recovery procedure.
        :param state_add_options:                   State options that will be added to the initial state.
        :param state_remove_options:                State options that will be removed from the initial state.
        """
        ForwardAnalysis.__init__(self, order_jobs=base_graph is not None)
        CFGBase.__init__(
            self,
            "emulated",
            context_sensitivity_level,
            normalize=normalize,
            resolve_indirect_jumps=resolve_indirect_jumps,
            indirect_jump_resolvers=indirect_jump_resolvers,
            indirect_jump_target_limit=indirect_jump_target_limit,
            model=model,
        )

        if start is not None:
            l.warning("`start` is deprecated. Please consider using `starts` instead in your code.")
            self._starts = (start,)
        else:
            if isinstance(starts, (list, set)):
                self._starts = tuple(starts)
            elif isinstance(starts, tuple) or starts is None:
                self._starts = starts
            else:
                raise AngrCFGError("Unsupported type of the `starts` argument.")

        if enable_advanced_backward_slicing or enable_symbolic_back_traversal:
            l.warning("`advanced backward slicing` and `symbolic back traversal` are deprecated.")
            l.warning(
                "Please use `resolve_indirect_jumps` to resolve indirect jumps using different resolvers instead."
            )

        self._iropt_level = iropt_level
        self._avoid_runs = avoid_runs
        self._enable_function_hints = enable_function_hints
        self._call_depth = call_depth
        self._call_tracing_filter = call_tracing_filter
        self._initial_state = initial_state
        self._keep_state = keep_state
        self._advanced_backward_slicing = enable_advanced_backward_slicing
        self._enable_symbolic_back_traversal = enable_symbolic_back_traversal
        self._additional_edges = additional_edges if additional_edges else {}
        self._max_steps = max_steps
        self._state_add_options = state_add_options if state_add_options is not None else set()
        self._state_remove_options = state_remove_options if state_remove_options is not None else set()
        self._state_add_options.update([o.SYMBOL_FILL_UNCONSTRAINED_MEMORY, o.SYMBOL_FILL_UNCONSTRAINED_REGISTERS])

        # add the track_memory_option if the enable function hint flag is set
        if self._enable_function_hints and o.TRACK_MEMORY_ACTIONS not in self._state_add_options:
            self._state_add_options.add(o.TRACK_MEMORY_ACTIONS)

        # more initialization

        self._symbolic_function_initial_state = {}
        self._function_input_states = None
        self._unresolvable_runs = set()

        # Stores the index for each CFGNode in this CFG after a quasi-topological sort (currently a DFS)
        # TODO: remove it since it's no longer used
        self._quasi_topological_order = {}
        # A copy of all entry points in this CFG. Integers
        self._entry_points = []

        self._max_iterations = max_iterations
        self._address_whitelist = set(address_whitelist) if address_whitelist is not None else None
        self._base_graph = base_graph
        self._node_addr_visiting_order = []

        if self._base_graph:
            sorted_nodes = GraphUtils.quasi_topological_sort_nodes(self._base_graph)
            self._node_addr_visiting_order = [n.addr for n in sorted_nodes]

        self._sanitize_parameters()

        self._executable_address_ranges = []
        self._executable_address_ranges = self._executable_memory_regions()

        # These save input states of functions. It will be discarded after the CFG is constructed
        self._function_input_states = {}
        self._loop_back_edges = []
        self._overlapped_loop_headers = []
        self._pending_function_hints = set()
        # A dict to log edges and the jumpkind between each basic block
        self._edge_map = defaultdict(list)

        self._model._iropt_level = self._iropt_level

        self._start_keys = []  # a list of block IDs of all starts

        # For each call, we are always getting two exits: an Ijk_Call that
        # stands for the real call exit, and an Ijk_Ret that is a simulated exit
        # for the retn address. There are certain cases that the control flow
        # never returns to the next instruction of a callsite due to
        # imprecision of the concrete execution. So we save those simulated
        # exits here to increase our code coverage. Of course the real retn from
        # that call always precedes those "fake" retns.
        self._pending_jobs = defaultdict(list)  # Dict[BlockID, List[PendingJob]]

        # Counting how many times a basic block is traced into
        self._traced_addrs = defaultdict(lambda: defaultdict(int))

        # A dict that collects essential parameters to properly reconstruct initial state for a block
        self._block_artifacts = {}
        self._analyzed_addrs = set()
        self._non_returning_functions = set()

        self._pending_edges = defaultdict(list)

        if not no_construct:
            self._initialize_cfg()
            self._analyze()

    #
    # Public methods
    #

    def copy(self) -> CFGEmulated:
        """
        Make a copy of the CFG.

        :return: A copy of the CFG instance.
        """
        new_cfg = CFGEmulated.__new__(CFGEmulated)
        super().make_copy(new_cfg)

        new_cfg._indirect_jump_target_limit = self._indirect_jump_target_limit
        new_cfg.named_errors = defaultdict(list, self.named_errors)
        new_cfg.errors = list(self.errors)
        new_cfg._fail_fast = self._fail_fast
        new_cfg._max_steps = self._max_steps
        new_cfg.project = self.project

        # Intelligently (or stupidly... you tell me) fill it up
        new_cfg._edge_map = self._edge_map.copy()
        new_cfg._loop_back_edges = self._loop_back_edges[::]
        new_cfg._executable_address_ranges = self._executable_address_ranges[::]
        new_cfg._unresolvable_runs = self._unresolvable_runs.copy()
        new_cfg._overlapped_loop_headers = self._overlapped_loop_headers[::]
        new_cfg._thumb_addrs = self._thumb_addrs.copy()
        new_cfg._keep_state = self._keep_state

        return new_cfg

    def resume(self, starts=None, max_steps=None):
        """
        Resume a paused or terminated control flow graph recovery.

        :param iterable starts: A collection of new starts to resume from. If `starts` is None, we will resume CFG
                                recovery from where it was paused before.
        :param int max_steps:   The maximum number of blocks on the longest path starting from each start before pausing
                                the recovery.
        :return: None
        """

        self._should_abort = False

        self._starts = starts
        self._max_steps = max_steps

        if self._starts is None:
            self._starts = []

        if self._starts:
            self._sanitize_starts()

        self._analyze()

    def remove_cycles(self):
        """
        Forces graph to become acyclic, removes all loop back edges and edges between overlapped loop headers and their
        successors.
        """
        # loop detection
        # only detect loops after potential graph normalization
        if not self._loop_back_edges:
            l.debug("Detecting loops...")
            self._detect_loops()

        l.debug("Removing cycles...")
        l.debug("There are %d loop back edges.", len(self._loop_back_edges))
        l.debug("And there are %d overlapping loop headers.", len(self._overlapped_loop_headers))
        # First break all detected loops
        for b1, b2 in self._loop_back_edges:
            if self._graph.has_edge(b1, b2):
                l.debug("Removing loop back edge %s -> %s", b1, b2)
                self._graph.remove_edge(b1, b2)
        # Then remove all outedges from overlapped loop headers
        for b in self._overlapped_loop_headers:
            successors = self._graph.successors(b)
            for succ in successors:
                self._graph.remove_edge(b, succ)
                l.debug("Removing partial loop header edge %s -> %s", b, succ)

    def downsize(self):
        """
        Remove saved states from all CFGNodes to reduce memory usage.

        :return: None
        """

        for cfg_node in self._nodes.values():
            cfg_node.downsize()

    def unroll_loops(self, max_loop_unrolling_times):
        """
        Unroll loops for each function. The resulting CFG may still contain loops due to recursion, function calls, etc.

        :param int max_loop_unrolling_times: The maximum iterations of unrolling.
        :return: None
        """

        if not isinstance(max_loop_unrolling_times, int) or max_loop_unrolling_times < 0:
            raise AngrCFGError(
                "Max loop unrolling times must be set to an integer greater than or equal to 0 if "
                + "loop unrolling is enabled."
            )

        def _unroll(graph: networkx.DiGraph, loop: Loop):
            """
            The loop callback method where loops are unrolled.

            :param graph: The control flow graph.
            :param loop: The loop instance.
            :return: None
            """

            for back_edge in loop.continue_edges:
                loop_body_addrs = {n.addr for n in loop.body_nodes}
                src_blocknode: BlockNode = back_edge[0]
                dst_blocknode: BlockNode = back_edge[1]

                for src in self.model.get_all_nodes(src_blocknode.addr):
                    for dst in graph.successors(src):
                        if dst.addr != dst_blocknode.addr:
                            continue

                        # Duplicate the dst node
                        new_dst = dst.copy()
                        new_dst.looping_times = dst.looping_times + 1
                        if (
                            new_dst not in graph
                            and
                            # If the new_dst is already in the graph, we don't want to keep unrolling
                            # the this loop anymore since it may *create* a new loop. Of course we
                            # will lose some edges in this way, but in general it is acceptable.
                            new_dst.looping_times <= max_loop_unrolling_times
                        ):
                            # Log all successors of the dst node
                            dst_successors = graph.successors(dst)
                            # Add new_dst to the graph
                            edge_data = graph.get_edge_data(src, dst)
                            graph.add_edge(src, new_dst, **edge_data)
                            for ds in dst_successors:
                                if ds.looping_times == 0 and ds.addr not in loop_body_addrs:
                                    edge_data = graph.get_edge_data(dst, ds)
                                    graph.add_edge(new_dst, ds, **edge_data)

                        graph.remove_edge(src, dst)

        self._detect_loops(loop_callback=_unroll)

    def force_unroll_loops(self, max_loop_unrolling_times):
        """
        Unroll loops globally. The resulting CFG does not contain any loop, but this method is slow on large graphs.

        :param int max_loop_unrolling_times: The maximum iterations of unrolling.
        :return: None
        """

        assert self._starts is not None

        if not isinstance(max_loop_unrolling_times, int) or max_loop_unrolling_times < 0:
            raise AngrCFGError(
                "Max loop unrolling times must be set to an integer greater than or equal to 0 if "
                + "loop unrolling is enabled."
            )

        # Traverse the CFG and try to find the beginning of loops
        loop_backedges = []

        start = self._starts[0]
        if isinstance(start, tuple):
            start, _ = start  # pylint: disable=unpacking-non-sequence
        start_node = self.model.get_any_node(start)
        if start_node is None:
            raise AngrCFGError("Cannot find start node when trying to unroll loops. The CFG might be empty.")

        graph_copy = networkx.DiGraph(self.graph)

        while True:
            cycles_iter = networkx.simple_cycles(graph_copy)
            try:
                cycle = next(cycles_iter)
            except StopIteration:
                break

            loop_backedge = (None, None)

            for n in networkx.dfs_preorder_nodes(graph_copy, source=start_node):
                if n in cycle:
                    idx = cycle.index(n)
                    loop_backedge = (cycle[-1], cycle[idx]) if idx == 0 else (cycle[idx - 1], cycle[idx])
                    break

            if loop_backedge not in loop_backedges:
                loop_backedges.append(loop_backedge)

            # Create a common end node for all nodes whose out_degree is 0
            end_nodes = [n for n in graph_copy.nodes() if graph_copy.out_degree(n) == 0]
            new_end_node = "end_node"

            if not end_nodes:
                # We gotta randomly break a loop
                cycles = sorted(networkx.simple_cycles(graph_copy), key=len)
                first_cycle = cycles[0]
                if len(first_cycle) == 1:
                    graph_copy.remove_edge(first_cycle[0], first_cycle[0])
                else:
                    graph_copy.remove_edge(first_cycle[0], first_cycle[1])
                end_nodes = [n for n in graph_copy.nodes() if graph_copy.out_degree(n) == 0]

            for en in end_nodes:
                graph_copy.add_edge(en, new_end_node)

            # postdoms = self.immediate_postdominators(new_end_node, target_graph=graph_copy)
            # reverse_postdoms = defaultdict(list)
            # for k, v in postdoms.items():
            #    reverse_postdoms[v].append(k)

            # Find all loop bodies
            # for src, dst in loop_backedges:
            #    nodes_in_loop = { src, dst }

            #    while True:
            #        new_nodes = set()

            #        for n in nodes_in_loop:
            #            if n in reverse_postdoms:
            #                for node in reverse_postdoms[n]:
            #                    if node not in nodes_in_loop:
            #                        new_nodes.add(node)

            #        if not new_nodes:
            #            break

            #        nodes_in_loop |= new_nodes

            # Unroll the loop body
            # TODO: Finish the implementation

            graph_copy.remove_node(new_end_node)
            src, dst = loop_backedge
            assert src is not None and dst is not None
            if graph_copy.has_edge(src, dst):  # It might have been removed before
                # Duplicate the dst node
                new_dst = dst.copy()
                new_dst.looping_times = dst.looping_times + 1
                if (
                    new_dst not in graph_copy
                    and
                    # If the new_dst is already in the graph, we don't want to keep unrolling
                    # the this loop anymore since it may *create* a new loop. Of course we
                    # will lose some edges in this way, but in general it is acceptable.
                    new_dst.looping_times <= max_loop_unrolling_times
                ):
                    # Log all successors of the dst node
                    dst_successors = list(graph_copy.successors(dst))
                    # Add new_dst to the graph
                    edge_data = graph_copy.get_edge_data(src, dst)
                    graph_copy.add_edge(src, new_dst, **edge_data)
                    for ds in dst_successors:
                        if ds.looping_times == 0 and ds not in cycle:
                            edge_data = graph_copy.get_edge_data(dst, ds)
                            graph_copy.add_edge(new_dst, ds, **edge_data)
                # Remove the original edge
                graph_copy.remove_edge(src, dst)

        # Update loop backedges
        self._loop_back_edges = loop_backedges

        self.model.graph = graph_copy

    def immediate_dominators(self, start, target_graph=None):
        """
        Get all immediate dominators of sub graph from given node upwards.

        :param str start: id of the node to navigate forwards from.
        :param networkx.classes.digraph.DiGraph target_graph: graph to analyse, default is self.graph.

        :return: each node of graph as index values, with element as respective node's immediate dominator.
        :rtype: dict
        """
        return self._immediate_dominators(start, target_graph=target_graph, reverse_graph=False)

    def immediate_postdominators(self, end, target_graph=None):
        """
        Get all immediate postdominators of sub graph from given node upwards.

        :param str start: id of the node to navigate forwards from.
        :param networkx.classes.digraph.DiGraph target_graph: graph to analyse, default is self.graph.

        :return: each node of graph as index values, with element as respective node's immediate dominator.
        :rtype: dict
        """
        return self._immediate_dominators(end, target_graph=target_graph, reverse_graph=True)

    def remove_fakerets(self):
        """
        Get rid of fake returns (i.e., Ijk_FakeRet edges) from this CFG

        :return: None
        """
        fakeret_edges = [
            (src, dst) for src, dst, data in self.graph.edges(data=True) if data["jumpkind"] == "Ijk_FakeRet"
        ]
        self.graph.remove_edges_from(fakeret_edges)

    def get_topological_order(self, cfg_node):
        """
        Get the topological order of a CFG Node.

        :param cfg_node: A CFGNode instance.
        :return: An integer representing its order, or None if the CFGNode does not exist in the graph.
        """

        if not self._quasi_topological_order:
            self._quasi_topological_sort()

        return self._quasi_topological_order.get(cfg_node, None)

    def get_subgraph(self, starting_node, block_addresses):
        """
        Get a sub-graph out of a bunch of basic block addresses.

        :param CFGNode starting_node: The beginning of the subgraph
        :param iterable block_addresses: A collection of block addresses that should be included in the subgraph if
                                        there is a path between `starting_node` and a CFGNode with the specified
                                        address, and all nodes on the path should also be included in the subgraph.
        :return: A new CFG that only contain the specific subgraph.
        :rtype: CFGEmulated
        """

        graph = networkx.DiGraph()

        if starting_node not in self.graph:
            raise AngrCFGError(
                f'get_subgraph(): the specified "starting_node" {starting_node} does not exist in the current CFG.'
            )

        addr_set = set(block_addresses)

        graph.add_node(starting_node)
        queue = [starting_node]

        while queue:
            node = queue.pop()
            for _, dst, data in self.graph.out_edges([node], data=True):
                if dst not in graph and dst.addr in addr_set:
                    graph.add_edge(node, dst, **data)
                    queue.append(dst)

        cfg = self.copy()
        cfg._graph = graph
        cfg._starts = (starting_node.addr,)

        return cfg

    def get_function_subgraph(self, start, max_call_depth=None):
        """
        Get a sub-graph of a certain function.

        :param start: The function start. Currently it should be an integer.
        :param max_call_depth: Call depth limit. None indicates no limit.
        :return: A CFG instance which is a sub-graph of self.graph
        """

        # FIXME: syscalls are not supported
        # FIXME: start should also take a CFGNode instance

        start_node = self.model.get_any_node(start)
        assert start_node is not None

        node_wrapper = (start_node, 0)
        stack: list[tuple[CFGNode, int]] = [node_wrapper]
        traversed_nodes = {start_node}
        subgraph_nodes = {start_node}

        while stack:
            nw = stack.pop()
            n, call_depth = nw[0], nw[1]

            # Get successors
            edges = self.graph.out_edges(n, data=True)

            for _, dst, data in edges:
                assert dst is not None
                if dst not in traversed_nodes:
                    # We see a new node!
                    traversed_nodes.add(dst)

                    if data["jumpkind"] == "Ijk_Call":
                        if max_call_depth is None or (max_call_depth is not None and call_depth < max_call_depth):
                            subgraph_nodes.add(dst)
                            new_nw = (dst, call_depth + 1)
                            stack.append(new_nw)
                    elif data["jumpkind"] == "Ijk_Ret":
                        if call_depth > 0:
                            subgraph_nodes.add(dst)
                            new_nw = (dst, call_depth - 1)
