"""A compiled JSONPath ready to be applied to a JSON string or Python object."""

from __future__ import annotations

import itertools
from typing import TYPE_CHECKING
from typing import AsyncIterable
from typing import Iterable
from typing import List
from typing import Optional
from typing import Tuple
from typing import TypeVar
from typing import Union

from jsonpath._data import load_data
from jsonpath.fluent_api import Query
from jsonpath.match import FilterContextVars
from jsonpath.match import JSONPathMatch
from jsonpath.segments import JSONPathRecursiveDescentSegment
from jsonpath.selectors import IndexSelector
from jsonpath.selectors import NameSelector

if TYPE_CHECKING:
    from jsonpath._types import JSONData

    from .env import JSONPathEnvironment
    from .segments import JSONPathSegment


class JSONPath:
    """A compiled JSONPath ready to be applied to a JSON string or Python object.

    Arguments:
        env: The `JSONPathEnvironment` this path is bound to.
        segments: An iterable of `JSONPathSegment` instances, as generated by
            a `Parser`.
        pseudo_root: Indicates if target JSON values should be wrapped in a single-
            element array, so as to make the target root value selectable.


    Attributes:
        env: The `JSONPathEnvironment` this path is bound to.
        selectors: The `JSONPathSelector` instances that make up this path.
    """

    __slots__ = ("env", "pseudo_root", "segments")

    def __init__(
        self,
        *,
        env: JSONPathEnvironment,
        segments: Iterable[JSONPathSegment],
        pseudo_root: bool = False,
    ) -> None:
        self.env = env
        self.segments = tuple(segments)
        self.pseudo_root = pseudo_root

    def __str__(self) -> str:
        return self.env.root_token + "".join(str(segment) for segment in self.segments)

    def __eq__(self, __value: object) -> bool:
        return isinstance(__value, JSONPath) and self.segments == __value.segments

    def __hash__(self) -> int:
        return hash(self.segments)

    def findall(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> List[object]:
        """Find all objects in `data` matching the given JSONPath `path`.

        If `data` is a string or a file-like objects, it will be loaded
        using `json.loads()` and the default `JSONDecoder`.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A list of matched objects. If there are no matches, the list will
            be empty.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        return [
            match.obj for match in self.finditer(data, filter_context=filter_context)
        ]

    def finditer(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Iterable[JSONPathMatch]:
        """Generate `JSONPathMatch` objects for each match.

        If `data` is a string or a file-like objects, it will be loaded
        using `json.loads()` and the default `JSONDecoder`.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            An iterator yielding `JSONPathMatch` objects for each match.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        _data = load_data(data)
        path = self.env.pseudo_root_token if self.pseudo_root else self.env.root_token

        matches: Iterable[JSONPathMatch] = [
            JSONPathMatch(
                filter_context=filter_context or {},
                obj=[_data] if self.pseudo_root else _data,
                parent=None,
                path=path,
                parts=(),
                root=_data,
            )
        ]

        for segment in self.segments:
            matches = segment.resolve(matches)

        return matches

    async def findall_async(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> List[object]:
        """An async version of `findall()`."""
        return [
            match.obj
            async for match in await self.finditer_async(
                data, filter_context=filter_context
            )
        ]

    async def finditer_async(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> AsyncIterable[JSONPathMatch]:
        """An async version of `finditer()`."""
        _data = load_data(data)
        path = self.env.pseudo_root_token if self.pseudo_root else self.env.root_token

        async def root_iter() -> AsyncIterable[JSONPathMatch]:
            yield self.env.match_class(
                filter_context=filter_context or {},
                obj=[_data] if self.pseudo_root else _data,
                parent=None,
                path=path,
                parts=(),
                root=_data,
            )

        matches: AsyncIterable[JSONPathMatch] = root_iter()

        for segment in self.segments:
            matches = segment.resolve_async(matches)

        return matches

    def match(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Union[JSONPathMatch, None]:
        """Return a `JSONPathMatch` instance for the first object found in _data_.

        `None` is returned if there are no matches.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A `JSONPathMatch` object for the first match, or `None` if there were
                no matches.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        try:
            return next(iter(self.finditer(data, filter_context=filter_context)))
        except StopIteration:
            return None

    def query(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Query:
        """Return a `Query` iterator over matches found by applying this path to _data_.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A query iterator.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        return Query(self.finditer(data, filter_context=filter_context), self.env)

    def empty(self) -> bool:
        """Return `True` if this path has no selectors."""
        return not bool(self.segments)

    def singular_query(self) -> bool:
        """Return `True` if this JSONPath query is a singular query."""
        for segment in self.segments:
            if isinstance(segment, JSONPathRecursiveDescentSegment):
                return False

            if len(segment.selectors) == 1 and isinstance(
                segment.selectors[0], (NameSelector, IndexSelector)
            ):
                continue

            return False

        return True


class CompoundJSONPath:
    """Multiple `JSONPath`s combined."""

    __slots__ = ("env", "path", "paths")

    def __init__(
        self,
        *,
        env: JSONPathEnvironment,
        path: Union[JSONPath, CompoundJSONPath],
        paths: Iterable[Tuple[str, JSONPath]] = (),
    ) -> None:
        self.env = env
        self.path = path
        self.paths = tuple(paths)

    def __str__(self) -> str:
        buf: List[str] = [str(self.path)]
        for op, path in self.paths:
            buf.append(f" {op} ")
            buf.append(str(path))
        return "".join(buf)

    def __eq__(self, __value: object) -> bool:
        return (
            isinstance(__value, CompoundJSONPath)
            and self.path == __value.path
            and self.paths == __value.paths
        )

    def __hash__(self) -> int:
        return hash((self.path, self.paths))

    def findall(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> List[object]:
        """Find all objects in `data` matching the given JSONPath `path`.

        If `data` is a string or a file-like objects, it will be loaded
        using `json.loads()` and the default `JSONDecoder`.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A list of matched objects. If there are no matches, the list will
                be empty.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        objs = self.path.findall(data, filter_context=filter_context)

        for op, path in self.paths:
            _objs = path.findall(data, filter_context=filter_context)
            if op == self.env.union_token:
                objs.extend(_objs)
            else:
                assert op == self.env.intersection_token, op
                objs = [obj for obj in objs if obj in _objs]

        return objs

    def finditer(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Iterable[JSONPathMatch]:
        """Generate `JSONPathMatch` objects for each match.

        If `data` is a string or a file-like objects, it will be loaded
        using `json.loads()` and the default `JSONDecoder`.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            An iterator yielding `JSONPathMatch` objects for each match.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        matches = self.path.finditer(data, filter_context=filter_context)

        for op, path in self.paths:
            _matches = path.finditer(data, filter_context=filter_context)
            if op == self.env.union_token:
                matches = itertools.chain(matches, _matches)
            else:
                assert op == self.env.intersection_token
                _objs = [match.obj for match in _matches]
                matches = (match for match in matches if match.obj in _objs)

        return matches

    def match(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Union[JSONPathMatch, None]:
        """Return a `JSONPathMatch` instance for the first object found in _data_.

        `None` is returned if there are no matches.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A `JSONPathMatch` object for the first match, or `None` if there were
                no matches.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        try:
            return next(iter(self.finditer(data, filter_context=filter_context)))
        except StopIteration:
            return None

    async def findall_async(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> List[object]:
        """An async version of `findall()`."""
        objs = await self.path.findall_async(data, filter_context=filter_context)

        for op, path in self.paths:
            _objs = await path.findall_async(data, filter_context=filter_context)
            if op == self.env.union_token:
                objs.extend(_objs)
            else:
                assert op == self.env.intersection_token
                objs = [obj for obj in objs if obj in _objs]

        return objs

    async def finditer_async(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> AsyncIterable[JSONPathMatch]:
        """An async version of `finditer()`."""
        matches = await self.path.finditer_async(data, filter_context=filter_context)

        for op, path in self.paths:
            _matches = await path.finditer_async(data, filter_context=filter_context)
            if op == self.env.union_token:
                matches = _achain(matches, _matches)
            else:
                assert op == self.env.intersection_token
                _objs = [match.obj async for match in _matches]
                matches = (match async for match in matches if match.obj in _objs)

        return matches

    def query(
        self, data: JSONData, *, filter_context: Optional[FilterContextVars] = None
    ) -> Query:
        """Return a `Query` iterator over matches found by applying this path to _data_.

        Arguments:
            data: A JSON document or Python object implementing the `Sequence`
                or `Mapping` interfaces.
            filter_context: Arbitrary data made available to filters using
                the _filter context_ selector.

        Returns:
            A query iterator.

        Raises:
            JSONPathSyntaxError: If the path is invalid.
            JSONPathTypeError: If a filter expression attempts to use types in
                an incompatible way.
        """
        return Query(self.finditer(data, filter_context=filter_context), self.env)

    def union(self, path: JSONPath) -> CompoundJSONPath:
        """Union of this path and another path."""
        return self.__class__(
            env=self.env,
            path=self.path,
            paths=self.paths + ((self.env.union_token, path),),
        )

    def intersection(self, path: JSONPath) -> CompoundJSONPath:
        """Intersection of this path and another path."""
        return self.__class__(
            env=self.env,
            path=self.path,
            paths=self.paths + ((self.env.intersection_token, path),),
        )


T = TypeVar("T")


async def _achain(*iterables: AsyncIterable[T]) -> AsyncIterable[T]:
    for it in iterables:
        async for element in it:
            yield element
