# Copyright (c) 2018-2019 Robin Jarry
# Copyright (c) 2020 6WIND S.A.
# Copyright (c) 2021 RACOM s.r.o.
# SPDX-License-Identifier: MIT

import os
from typing import IO, Any, Callable, Iterator, List, Optional, Sequence, Tuple, Union

from _libyang import ffi, lib
from .data import (
    DNode,
    data_format,
    data_type,
    newval_flags,
    parser_flags,
    validation_flags,
)
from .schema import Module, SNode, schema_in_format
from .util import (
    DataType,
    IOType,
    LibyangError,
    LibyangErrorItem,
    c2str,
    data_load,
    str2c,
)


# -------------------------------------------------------------------------------------
@ffi.def_extern(name="lypy_module_imp_data_free_clb")
def libyang_c_module_imp_data_free_clb(cdata, user_data):
    instance = ffi.from_handle(user_data)
    instance.free_module_data(cdata)


# -------------------------------------------------------------------------------------
@ffi.def_extern(name="lypy_module_imp_clb")
def libyang_c_module_imp_clb(
    mod_name,
    mod_rev,
    submod_name,
    submod_rev,
    user_data,
    fmt,
    module_data,
    free_module_data,
):
    """
    Implement the C callback function for loading modules from any location.

    :arg c_str mod_name:
        The YANG module name
    :arg c_str mod_rev:
        The YANG module revision
    :arg c_str submod_name:
        The YANG submodule name
    :arg c_str submod_rev:
        The YANG submodule revision
    :arg user_data:
        The user data provided by user during registration. In this implementation
        it is always considered to be handle of Python object
    :arg fmt:
        The output pointer where to set the format of schema
    :arg module_data:
        The output pointer where to set the schema data itself
    :arg free_module_data:
        The output pointer of callback function which will be called when the schema
        data are no longer needed

    :returns:
        The LY_SUCCESS in case the needed YANG (sub)module schema was found
        The LY_ENOT in case the needed YANG (sub)module schema was not found
    """
    fmt[0] = lib.LYS_IN_UNKNOWN
    module_data[0] = ffi.NULL
    free_module_data[0] = lib.lypy_module_imp_data_free_clb
    instance = ffi.from_handle(user_data)
    ret = instance.get_module_data(
        c2str(mod_name), c2str(mod_rev), c2str(submod_name), c2str(submod_rev)
    )
    if ret is None:
        return lib.LY_ENOT
    in_fmt, content = ret
    fmt[0] = schema_in_format(in_fmt)
    module_data[0] = content
    return lib.LY_SUCCESS


# -------------------------------------------------------------------------------------
class ContextExternalModuleLoader:
    __slots__ = (
        "_cdata",
        "_module_data_clb",
        "_cffi_handle",
        "_cdata_modules",
    )

    def __init__(self, cdata) -> None:
        self._cdata = cdata  # C type: "struct ly_ctx *"
        self._module_data_clb = None
        self._cffi_handle = ffi.new_handle(self)
        self._cdata_modules = []

    def free_module_data(self, cdata) -> None:
        """
        Free previously stored data, obtained after a get_module_data.

        :arg cdata:
            The pointer to YANG modelu schema (c_str), which shall be released from memory
        """
        self._cdata_modules.remove(cdata)

    def get_module_data(
        self,
        mod_name: Optional[str],
        mod_rev: Optional[str],
        submod_name: Optional[str],
        submod_rev: Optional[str],
    ) -> Optional[Tuple[str, str]]:
        """
        Get the YANG module schema data based requirements from libyang_c_module_imp_clb
        function and forward that request to user Python based callback function.

        The returned data from callback function are stored within the context to make sure
        of no memory access issues. These data a stored until the free_module_data function
        is called directly by libyang.

        :arg self
            This instance on context
        :arg mod_name:
            The optional YANG module name
        :arg mod_rev:
            The optional YANG module revision
        :arg submod_name:
            The optional YANG submodule name
        :arg submod_rev:
            The optional YANG submodule revision

        :returns:
            Tuple of format string and YANG (sub)module schema
        """
        if self._module_data_clb is None:
            return None
        ret = self._module_data_clb(mod_name, mod_rev, submod_name, submod_rev)
        if ret is None:
            return None
        fmt_str, module_data = ret
        module_data_c = str2c(module_data)
        self._cdata_modules.append(module_data_c)
        return fmt_str, module_data_c

    def set_module_data_clb(
        self,
        clb: Optional[
            Callable[
                [Optional[str], Optional[str], Optional[str], Optional[str]],
                Optional[Tuple[str, str]],
            ]
        ] = None,
    ) -> None:
        """
        Set the callback function, which will be called if libyang context would like to
        load module or submodule, which is not locally available in context path(s).

        :arg self
            This instance on context
        :arg clb:
            The callback function. The expected arguments are:
                mod_name: Module name
                mod_rev: Module revision
                submod_name: Submodule name
                submod_rev: Submodule revision
            The expeted return value is either:
                tuple of:
                    format: The string format of the loaded data
                    data: The YANG (sub)module data as string
                or None in case of error
        """
        self._module_data_clb = clb
        if clb is None:
            lib.ly_ctx_set_module_imp_clb(self._cdata, ffi.NULL, ffi.NULL)
        else:
            lib.ly_ctx_set_module_imp_clb(
                self._cdata, lib.lypy_module_imp_clb, self._cffi_handle
            )


# -------------------------------------------------------------------------------------
class Context:
    __slots__ = (
        "cdata",
        "external_module_loader",
        "__dict__",
    )

    def __init__(
        self,
        search_path: Optional[str] = None,
        disable_searchdirs: bool = False,
        disable_searchdir_cwd: bool = True,
        explicit_compile: Optional[bool] = False,
        leafref_extended: bool = False,
        leafref_linking: bool = False,
        builtin_plugins_only: bool = False,
        all_implemented: bool = False,
        enable_imp_features: bool = False,
        compile_obsolete: bool = False,
        yanglib_path: Optional[str] = None,
        yanglib_fmt: str = "json",
        cdata=None,  # C type: "struct ly_ctx *"
    ):
        if cdata is not None:
            self.cdata = ffi.cast("struct ly_ctx *", cdata)
            self.external_module_loader = ContextExternalModuleLoader(self.cdata)
            return  # already initialized

        options = 0
        if disable_searchdirs:
            options |= lib.LY_CTX_DISABLE_SEARCHDIRS
        if disable_searchdir_cwd:
            options |= lib.LY_CTX_DISABLE_SEARCHDIR_CWD
        if explicit_compile:
            options |= lib.LY_CTX_EXPLICIT_COMPILE
        if leafref_extended:
            options |= lib.LY_CTX_LEAFREF_EXTENDED
        if leafref_linking:
            options |= lib.LY_CTX_LEAFREF_LINKING
        if builtin_plugins_only:
            options |= lib.LY_CTX_BUILTIN_PLUGINS_ONLY
        if all_implemented:
            options |= lib.LY_CTX_ALL_IMPLEMENTED
        if enable_imp_features:
            options |= lib.LY_CTX_ENABLE_IMP_FEATURES
        if compile_obsolete:
            options |= lib.LY_CTX_COMPILE_OBSOLETE
        # force priv parsed
        options |= lib.LY_CTX_SET_PRIV_PARSED

        self.cdata = None
        ctx = ffi.new("struct ly_ctx **")

        search_paths = []
        if "YANGPATH" in os.environ:
            search_paths.extend(os.environ["YANGPATH"].strip(": \t\r\n'\"").split(":"))
        elif "YANG_MODPATH" in os.environ:
            search_paths.extend(
                os.environ["YANG_MODPATH"].strip(": \t\r\n'\"").split(":")
            )
        if search_path:
            search_paths.extend(search_path.strip(": \t\r\n'\"").split(":"))

        search_paths = [path for path in search_paths if os.path.isdir(path)]
        search_path = ":".join(search_paths) if search_paths else None

        if yanglib_path is None:
            options |= lib.LY_CTX_NO_YANGLIBRARY
            if lib.ly_ctx_new(str2c(search_path), options, ctx) != lib.LY_SUCCESS:
                raise self.error("cannot create context")
        else:
            if yanglib_fmt == "json":
                fmt = lib.LYD_JSON
            else:
                fmt = lib.LYD_XML
            print("steweg", search_path, yanglib_path, yanglib_fmt, options)
            ret = lib.ly_ctx_new_ylpath(
                str2c(search_path), str2c(yanglib_path), fmt, options, ctx
            )
            if ret != lib.LY_SUCCESS:
                raise self.error("cannot create context")

        self.cdata = ffi.gc(
            ctx[0],
            lib.ly_ctx_destroy,
        )
        if not self.cdata:
            raise self.error("cannot create context")
        self.external_module_loader = ContextExternalModuleLoader(self.cdata)

    def compile_schema(self):
        ret = lib.ly_ctx_compile(self.cdata)
        if ret != lib.LY_SUCCESS:
            raise self.error("could not compile schema")

    def get_yanglib_data(self, content_id_format=""):
        dnode = ffi.new("struct lyd_node **")
        ret = lib.ly_ctx_get_yanglib_data(self.cdata, dnode, str2c(content_id_format))
        if ret != lib.LY_SUCCESS:
            raise self.error("cannot get yanglib data")
        return DNode.new(self, dnode[0])

    def destroy(self):
        if self.cdata is not None:
            if hasattr(ffi, "release"):
                ffi.release(self.cdata)  # causes ly_ctx_destroy to be called
            self.cdata = None

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self.destroy()

    def error(self, msg: str, *args) -> LibyangError:
        if args:
            msg = msg % args

        parts = [msg]
        errors = []

        if self.cdata:
            err = lib.ly_err_first(self.cdata)
            while err:
                m = c2str(err.msg) if err.msg else None
                dp = c2str(err.data_path) if err.data_path else None
                sp = c2str(err.schema_path) if err.schema_path else None
                ln = int(err.line) if err.line else None
                parts.extend(
                    tmpl.format(val)
                    for val, tmpl in [
                        (m, ": {}"),
                        (dp, ": Data path: {}"),
                        (sp, ": Schema path: {}"),
                        (ln, " (line {})"),
                    ]
                    if val is not None
                )
                errors.append(LibyangErrorItem(m, dp, sp, ln))
                err = err.next
            lib.ly_err_clean(self.cdata, ffi.NULL)

        msg = "".join(parts)
        return LibyangError(msg, errors=errors)

    def parse_module(
        self,
        in_data: Union[IO, str],
        in_type: IOType,
        fmt: str = "yang",
        features=None,
    ):
        data = ffi.new("struct ly_in **")
        data_keepalive = []
        ret = data_load(in_type, in_data, data, data_keepalive)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to read input data")

        feat = ffi.NULL
        if features:
            feat = ffi.new(f"char *[{len(features) + 1}]")
            features = [str2c(i) for i in features]
            for i, val in enumerate(features):
                feat[i] = val
            feat[len(features)] = ffi.NULL

        mod = ffi.new("struct lys_module **")
        fmt = schema_in_format(fmt)
        ret = lib.lys_parse(self.cdata, data[0], fmt, feat, mod)
        lib.ly_in_free(data[0], 0)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to parse module")

        return Module(self, mod[0])

    def parse_module_file(
        self, fileobj: IO, fmt: str = "yang", features=None
    ) -> Module:
        return self.parse_module(fileobj, IOType.FILE, fmt, features)

    def parse_module_str(self, s: str, fmt: str = "yang", features=None) -> Module:
        return self.parse_module(s, IOType.MEMORY, fmt, features)

    def load_module(
        self,
        name: str,
        revision: Optional[str] = None,
        enabled_features: Sequence[str] = (),
    ) -> Module:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        if enabled_features:
            features = tuple([str2c(f) for f in enabled_features] + [ffi.NULL])
        else:
            features = ffi.NULL
        mod = lib.ly_ctx_load_module(self.cdata, str2c(name), str2c(revision), features)
        if mod == ffi.NULL:
            raise self.error("cannot load module")

        return Module(self, mod)

    def get_module(self, name: str) -> Module:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        mod = lib.ly_ctx_get_module_latest(self.cdata, str2c(name))
        if mod == ffi.NULL:
            raise self.error("cannot get module")

        return Module(self, mod)

    def find_path(
        self,
        path: str,
        output: bool = False,
        root_node: Optional[SNode] = None,
    ) -> Iterator[SNode]:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")

        if root_node is not None:
            ctx_node = root_node.cdata
        else:
            ctx_node = ffi.NULL

        flags = 0
        if output:
            flags |= lib.LYS_FIND_XP_OUTPUT

        node_set = ffi.new("struct ly_set **")
        if (
            lib.lys_find_xpath(self.cdata, ctx_node, str2c(path), 0, node_set)
            != lib.LY_SUCCESS
        ):
            raise self.error("cannot find path")

        node_set = node_set[0]
        if node_set.count == 0:
            raise self.error("cannot find path")
        try:
            for i in range(node_set.count):
                yield SNode.new(self, node_set.snodes[i])
        finally:
            lib.ly_set_free(node_set, ffi.NULL)

    def find_xpath_atoms(
        self,
        path: str,
        output: bool = False,
        root_node: Optional["libyang.SNode"] = None,
    ) -> Iterator[SNode]:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")

        if root_node is not None:
            ctx_node = root_node.cdata
        else:
            ctx_node = ffi.NULL

        flags = lib.LYS_FIND_XP_OUTPUT if output else 0

        node_set = ffi.new("struct ly_set **")
        if (
            lib.lys_find_xpath_atoms(self.cdata, ctx_node, str2c(path), flags, node_set)
            != lib.LY_SUCCESS
        ):
            raise self.error("cannot find path")

        node_set = node_set[0]
        if node_set.count == 0:
            raise self.error("cannot find path")
        try:
            for i in range(node_set.count):
                yield SNode.new(self, node_set.snodes[i])
        finally:
            lib.ly_set_free(node_set, ffi.NULL)

    def find_jsonpath(
        self,
        path: str,
        root_node: Optional[SNode] = None,
        output: bool = False,
    ) -> Optional[SNode]:
        if root_node is not None:
            ctx_node = root_node.cdata
        else:
            ctx_node = ffi.NULL

        ret = lib.lys_find_path(self.cdata, ctx_node, str2c(path), output)
        if ret == ffi.NULL:
            return None
        return SNode.new(self, ret)

    def create_data_path(
        self,
        path: str,
        parent: Optional[DNode] = None,
        value: Any = None,
        update: bool = True,
        store_only: bool = False,
        rpc_output: bool = False,
        force_return_value: bool = True,
    ) -> Optional[DNode]:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        if value is not None:
            if isinstance(value, bool):
                value = str(value).lower()
            elif not isinstance(value, str):
                value = str(value)
        flags = newval_flags(
            update=update, store_only=store_only, rpc_output=rpc_output
        )
        dnode = ffi.new("struct lyd_node **")
        ret = lib.lyd_new_path(
            parent.cdata if parent else ffi.NULL,
            self.cdata,
            str2c(path),
            str2c(value),
            flags,
            dnode,
        )
        dnode = dnode[0]
        if ret != lib.LY_SUCCESS:
            err = lib.ly_err_last(self.cdata)
            if err != ffi.NULL and err.vecode != lib.LYVE_SUCCESS:
                raise self.error("cannot create data path: %s", path)
            lib.ly_err_clean(self.cdata, ffi.NULL)
        if not dnode and not force_return_value:
            return None

        if not dnode and parent:
            # This can happen when path points to an already created leaf and
            # its value does not change.
            # In that case, lookup the existing leaf and return it.
            node_set = ffi.new("struct ly_set **")
            ret = lib.lyd_find_xpath(parent.cdata, str2c(path), node_set)
            if ret != lib.LY_SUCCESS:
                raise self.error("cannot find path: %s", path)

            node_set = node_set[0]
            try:
                if not node_set or not node_set.count:
                    raise self.error("cannot find path: %s", path)
                dnode = node_set.dnodes[0]
            finally:
                lib.ly_set_free(node_set, ffi.NULL)

        if not dnode:
            raise self.error("cannot find created path")

        return DNode.new(self, dnode)

    def parse_op(
        self,
        fmt: str,
        in_type: IOType,
        in_data: Union[IO, str],
        dtype: DataType,
        parent: DNode = None,
        opaq: bool = False,
        strict: bool = False,
    ) -> DNode:
        fmt = data_format(fmt)
        data = ffi.new("struct ly_in **")
        data_keepalive = []
        dtype = data_type(dtype)
        ret = data_load(in_type, in_data, data, data_keepalive)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to read input data")

        flags = parser_flags(opaq=opaq, strict=strict)
        tree = ffi.new("struct lyd_node **", ffi.NULL)
        op = ffi.new("struct lyd_node **", ffi.NULL)
        par = ffi.new("struct lyd_node **", ffi.NULL)
        if parent is not None:
            par[0] = parent.cdata

        ret = lib.lyd_parse_op(self.cdata, par[0], data[0], fmt, dtype, flags, tree, op)
        lib.ly_in_free(data[0], 0)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to parse input data")

        return DNode.new(self, op[0])

    def parse_op_mem(
        self,
        fmt: str,
        data: str,
        dtype: DataType = DataType.DATA_YANG,
        parent: DNode = None,
        opaq: bool = False,
        strict: bool = False,
    ):
        return self.parse_op(
            fmt,
            in_type=IOType.MEMORY,
            in_data=data,
            dtype=dtype,
            parent=parent,
            opaq=opaq,
            strict=strict,
        )

    def parse_data(
        self,
        fmt: str,
        in_type: IOType,
        in_data: Union[str, bytes, IO],
        parent: DNode = None,
        no_state: bool = False,
        parse_only: bool = False,
        opaq: bool = False,
        ordered: bool = False,
        strict: bool = False,
        validate_present: bool = False,
        validate_multi_error: bool = False,
        store_only: bool = False,
        json_null: bool = False,
        json_string_datatypes: bool = False,
    ) -> Optional[DNode]:
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        parser_flgs = parser_flags(
            no_state=no_state,
            parse_only=parse_only,
            opaq=opaq,
            ordered=ordered,
            strict=strict,
            store_only=store_only,
            json_null=json_null,
            json_string_datatypes=json_string_datatypes,
        )
        validation_flgs = validation_flags(
            no_state=no_state,
            validate_present=validate_present,
            validate_multi_error=validate_multi_error,
        )
        fmt = data_format(fmt)
        encode = True
        if fmt == lib.LYD_LYB:
            encode = False
        data = ffi.new("struct ly_in **")
        data_keepalive = []
        ret = data_load(in_type, in_data, data, data_keepalive, encode)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to read input data")

        parent_cdata = parent.cdata if parent is not None else ffi.NULL
        dnode = ffi.new("struct lyd_node **")
        ret = lib.lyd_parse_data(
            self.cdata, parent_cdata, data[0], fmt, parser_flgs, validation_flgs, dnode
        )
        lib.ly_in_free(data[0], 0)
        if ret != lib.LY_SUCCESS:
            raise self.error("failed to parse data tree")

        dnode = dnode[0]
        if dnode == ffi.NULL:
            return None
        return DNode.new(self, dnode)

    def parse_data_mem(
        self,
        data: Union[str, bytes],
        fmt: str,
        parent: DNode = None,
        no_state: bool = False,
        parse_only: bool = False,
        opaq: bool = False,
        ordered: bool = False,
        strict: bool = False,
        validate_present: bool = False,
        validate_multi_error: bool = False,
        store_only: bool = False,
        json_null: bool = False,
        json_string_datatypes: bool = False,
    ) -> Optional[DNode]:
        return self.parse_data(
            fmt,
            in_type=IOType.MEMORY,
            in_data=data,
            parent=parent,
            no_state=no_state,
            parse_only=parse_only,
            opaq=opaq,
            ordered=ordered,
            strict=strict,
            validate_present=validate_present,
            validate_multi_error=validate_multi_error,
            store_only=store_only,
            json_null=json_null,
            json_string_datatypes=json_string_datatypes,
        )

    def parse_data_file(
        self,
        fileobj: IO,
        fmt: str,
        parent: DNode = None,
        no_state: bool = False,
        parse_only: bool = False,
        opaq: bool = False,
        ordered: bool = False,
        strict: bool = False,
        validate_present: bool = False,
        validate_multi_error: bool = False,
        store_only: bool = False,
        json_null: bool = False,
        json_string_datatypes: bool = False,
    ) -> Optional[DNode]:
        return self.parse_data(
            fmt,
            in_type=IOType.FD,
            in_data=fileobj,
            parent=parent,
            no_state=no_state,
            parse_only=parse_only,
            opaq=opaq,
            ordered=ordered,
            strict=strict,
            validate_present=validate_present,
            validate_multi_error=validate_multi_error,
            store_only=store_only,
            json_null=json_null,
            json_string_datatypes=json_string_datatypes,
        )

    def find_leafref_path_target_paths(self, leafref_path: str) -> List[str]:
        """
        Fetch all leafref targets of the specified path

        This is an enhanced version of lysc_node_lref_target() which will return
        a set of leafref target paths retrieved from the specified schema path.
        While lysc_node_lref_target() will only work on nodetype of LYS_LEAF and
        LYS_LEAFLIST this function will also evaluate other datatypes that may
        contain leafrefs such as LYS_UNION.  This does not, however, search for
        children with leafref targets.

        :arg self
            This instance on context
        :arg leafref_path:
            Path to node to search for leafref targets
        :returns List of target paths that the leafrefs of the specified node
                 point to.
        """
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        if leafref_path is None:
            raise RuntimeError("leafref_path must be defined")

        out = []

        node = lib.lys_find_path(self.cdata, ffi.NULL, str2c(leafref_path), 0)
        if node == ffi.NULL:
            raise self.error("leafref_path not found")

        node_set = ffi.new("struct ly_set **")
        if (
            lib.lysc_node_lref_targets(node, node_set) != lib.LY_SUCCESS
            or node_set[0] == ffi.NULL
            or node_set[0].count == 0
        ):
            raise self.error("leafref_path does not contain any leafref targets")

        node_set = node_set[0]
        for i in range(node_set.count):
            path = lib.lysc_path(node_set.snodes[i], lib.LYSC_PATH_DATA, ffi.NULL, 0)
            out.append(c2str(path))
            lib.free(path)

        lib.ly_set_free(node_set, ffi.NULL)

        return out

    def find_backlinks_paths(
        self, match_path: str = None, match_ancestors: bool = False
    ) -> List[str]:
        """
        Search entire schema for nodes that contain leafrefs and return as a
        list of schema node paths.

        Perform a complete scan of the schema tree looking for nodes that
        contain leafref entries. When a node contains a leafref entry, and
        match_path is specified, determine if reference points to match_path,
        if so add the node's path to returned list.  If no match_path is
        specified, the node containing the leafref is always added to the
        returned set.  When match_ancestors is true, will evaluate if match_path
        is self or an ansestor of self.

        This does not return the leafref targets, but the actual node that
        contains a leafref.

        :arg self
            This instance on context
        :arg match_path:
            Target path to use for matching
        :arg match_ancestors:
            Whether match_path is a base ancestor or an exact node
        :returns List of paths.  Exception of match_path is not found or if no
                 backlinks are found.
        """
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        out = []

        match_node = ffi.NULL
        if match_path is not None and match_path == "/" or match_path == "":
            match_path = None

        if match_path:
            match_node = lib.lys_find_path(self.cdata, ffi.NULL, str2c(match_path), 0)
            if match_node == ffi.NULL:
                raise self.error("match_path not found")

        node_set = ffi.new("struct ly_set **")
        if (
            lib.lysc_node_lref_backlinks(
                self.cdata, match_node, match_ancestors, node_set
            )
            != lib.LY_SUCCESS
            or node_set[0] == ffi.NULL
            or node_set[0].count == 0
        ):
            raise self.error("backlinks not found")

        node_set = node_set[0]
        for i in range(node_set.count):
            path = lib.lysc_path(node_set.snodes[i], lib.LYSC_PATH_DATA, ffi.NULL, 0)
            out.append(c2str(path))
            lib.free(path)

        lib.ly_set_free(node_set, ffi.NULL)

        return out

    def __iter__(self) -> Iterator[Module]:
        """
        Return an iterator that yields all implemented modules from the context
        """
        if self.cdata is None:
            raise RuntimeError("context already destroyed")
        idx = ffi.new("uint32_t *")
        mod = lib.ly_ctx_get_module_iter(self.cdata, idx)
        while mod:
            yield Module(self, mod)
            mod = lib.ly_ctx_get_module_iter(self.cdata, idx)

    def add_to_dict(self, orig_str: str) -> Any:
        cstr = ffi.new("char **")
        ret = lib.lydict_insert(self.cdata, str2c(orig_str), 0, cstr)
        if ret != lib.LY_SUCCESS:
            raise LibyangError("Unable to insert string into context dictionary")
        return cstr[0]

    def remove_from_dict(self, orig_str: str) -> None:
        lib.lydict_remove(self.cdata, str2c(orig_str))
