import param
import re

from typing import List, Optional, Set


class SearcherMixin:
    """Mixin that adds parameter search methods to param.Parameterized subclasses."""

    def _collect_all_param_names(self, visited: Optional[Set[int]] = None) -> Set[str]:
        """Recursively collect all param names from self and all nested Parameterized objects.

        Parameters
        ----------
        visited : set, optional
            Set of object ids already visited (to prevent cycles).

        Returns
        -------
        set[str]
            All param names reachable from self.
        """
        if visited is None:
            visited = set()

        results: Set[str] = set()

        def _check_value(value) -> None:
            if isinstance(value, param.Parameterized):
                _traverse(value)
            elif isinstance(value, (list, tuple)):
                for item in value:
                    _check_value(item)
            elif isinstance(value, dict):
                for v in value.values():
                    _check_value(v)

        def _traverse(obj: param.Parameterized) -> None:
            if id(obj) in visited:
                return
            visited.add(id(obj))
            for p_name in obj.param.objects():
                if p_name == "name":
                    continue
                results.add(p_name)
                _check_value(getattr(obj, p_name, None))

        _traverse(self)
        return results

    def _resolve_owner(self, owner_path: str):
        """Traverse owner_path from self and return the owning object.

        Parameters
        ----------
        owner_path : str
            Dotted path to the object that owns the parameter, e.g.
            ``""`` for self, ``"map_"`` for a direct sub-object, or
            ``"map_.layers[0].getFillColor"`` for a deeply nested object.

        Returns
        -------
        param.Parameterized
            The object that owns the parameter.
        """
        if not owner_path:
            return self
        tokens = re.split(r"\.(?![^\[]*\])", owner_path)
        obj = self
        for token in tokens:
            parts = re.findall(r"([^\[]+)|\[([^\]]+)\]", token)
            for attr, idx in parts:
                if attr:
                    obj = getattr(obj, attr)
                else:
                    idx = idx.strip("'\"")
                    obj = obj[int(idx)] if idx.isdigit() else obj[idx]
        return obj

    def get_param(self, param_name: str) -> param.Parameter:
        """Get a Parameter by flat name using self.mapping to locate its owner.

        Uses the flat ``{name: owner_path}`` mapping built by
        ``build_flat_mapping`` to resolve which object on ``self`` owns the
        parameter.  A mapping value of ``""`` means the parameter lives
        directly on ``self``.

        Use this when you have a plain parameter name and a pre-built mapping.
        For arbitrary nested traversal without a mapping, use
        ``get_param_by_path`` instead.

        Parameters
        ----------
        param_name : str
            Plain parameter name, e.g. ``"color"`` or ``"threshold"``.

        Returns
        -------
        param.Parameter
        """
        return self._resolve_owner(self.mapping[param_name]).param[param_name]

    def get_param_value(self, param_name: str):
        """Get the current value of a parameter using self.mapping to locate its owner.

        Equivalent to ``get_param`` but returns the live value rather than the
        ``param.Parameter`` descriptor.

        Parameters
        ----------
        param_name : str
            Plain parameter name, e.g. ``"color"`` or ``"threshold"``.
        """
        return getattr(self._resolve_owner(self.mapping[param_name]), param_name)

    def get_param_space(self, param_name: str):
        """Get the param namespace of the object that owns a parameter.

        Returns the ``.param`` accessor of whichever object (``self`` or a
        sub-object) owns ``param_name`` according to ``self.mapping``.  Useful
        for attaching watchers to the correct parameter space.

        Parameters
        ----------
        param_name : str
            Plain parameter name, e.g. ``"color"`` or ``"threshold"``.
        """
        return self._resolve_owner(self.mapping[param_name]).param

    def get_param_by_path(self, path: str) -> param.Parameter:
        """Get a Parameter by arbitrary dotted path, supporting index/key notation.

        Traverses the object tree by following the segments of ``path``
        without requiring a pre-built mapping.  Useful for one-off lookups or
        when the owning object is several levels deep.

        Use this for structural traversal with dotted paths.  For the common
        case of looking up a known parameter name via a flat mapping, use
        ``get_param`` instead.

        Parameters
        ----------
        path : str
            Dotted path, e.g. ``"layers[0].style.color"`` or
            ``"mapping['key'].threshold"``.

        Returns
        -------
        param.Parameter
        """
        tokens = re.split(r"\.(?![^\[]*\])", path)
        obj = self
        for token in tokens[:-1]:
            parts = re.findall(r"([^\[]+)|\[([^\]]+)\]", token)
            for attr, idx in parts:
                if attr:
                    obj = getattr(obj, attr)
                else:
                    idx = idx.strip("'\"")
                    obj = obj[int(idx)] if idx.isdigit() else obj[idx]

        last = tokens[-1]
        parts = re.findall(r"([^\[]+)|\[([^\]]+)\]", last)
        for attr, idx in parts[:-1]:
            if attr:
                obj = getattr(obj, attr)
            else:
                idx = idx.strip("'\"")
                obj = obj[int(idx)] if idx.isdigit() else obj[idx]

        final_attr = parts[-1][0]
        return obj.param.objects()[final_attr]

    def find_params(
        self, targets: List[str], prefix: str = "", visited: Optional[Set[int]] = None
    ) -> dict:
        """Find all occurrences of Parameters by name in a (nested) Parameterized object.

        Parameters
        ----------
        targets : list[str]
            The Parameter names to find.

        Returns
        -------
        dict[str, list[str]]
            Mapping of target name to list of dotted paths where it was found.
        """
        if visited is None:
            visited = set()

        results = {t: [] for t in targets}

        def _search(obj: param.Parameterized, pfx: str) -> None:
            obj_id = id(obj)
            if obj_id in visited:
                return
            visited.add(obj_id)

            def _check_value(value, path: str) -> None:
                if isinstance(value, param.Parameterized):
                    _search(value, path)
                elif isinstance(value, (list, tuple)):
                    for i, item in enumerate(value):
                        _check_value(item, f"{path}[{i}]")
                elif isinstance(value, dict):
                    for k, v in value.items():
                        _check_value(v, f"{path}[{k!r}]")

            for name in obj.param.objects():
                if name == "name":
                    continue
                full_name = f"{pfx}.{name}" if pfx else name
                if name in results:
                    results[name].append(full_name)
                _check_value(getattr(obj, name, None), full_name)

        _search(self, prefix)
        return results

    def build_flat_mapping(self) -> dict:
        """Build a flat mapping of all reachable param names to their owning object path.

        Discovers all parameters reachable from this object at any depth and
        maps each name to the dotted path of the object that owns it (e.g.
        ``""`` if it lives directly on self, ``"map_"`` for a direct
        sub-object, or ``"map_.layers[0].getFillColor"`` for a deeply nested
        object).

        Returns
        -------
        dict[str, str]
            Mapping of param name -> owner path (``""`` if owned by self).
        """
        targets = self._collect_all_param_names()

        found = self.find_params(list(targets))
        mapping = {}
        for name, paths in found.items():
            if not paths:
                continue
            # if len(paths) > 1:
            #     import warnings
            #     warnings.warn(
            #         f"Parameter '{name}' found at multiple paths: {paths}. "
            #         f"Using the shallowest path.",
            #         stacklevel=2,
            #     )
            # Prefer the shallowest path so direct params on self take precedence
            # over the same param name found deep inside a nested sub-object.
            chosen = min(paths, key=lambda p: len(p.split(".")))
            parts = chosen.split(".")
            # Store the full owner path (everything before the last segment).
            mapping[name] = ".".join(parts[:-1])
        return mapping

    def search_and_add_params(self: param.Parameterized, reference_parameters: List[str]) -> None:
        search_result = self.find_params(reference_parameters)
        for key, value in search_result.items():
            p = self.get_param_by_path(value[0])

            if key not in self.param:
                self.param.add_parameter(key, p)

            if self.param[key].allow_refs:
                self.param.update(**{key: p})
