from __future__ import annotations

import functools
import os
import shlex
import socket
import subprocess  # nosec
import sys
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar

if TYPE_CHECKING:
    from types import TracebackType

    from asgi_lifespan import LifespanManager
    from asgi_lifespan._types import ASGIApp
    from fastapi import FastAPI
    from httpx import AsyncClient

    from asynctor.compat import Self


T = TypeVar("T")
AsyncClientGenerator: TypeAlias = AsyncGenerator["AsyncClient"]


@asynccontextmanager
async def client_manager(
    app: FastAPI,
    base_url: str = "http://test",
    mount_lifespan: bool = True,
    timeout: int | float = 30,
    **kwargs,
) -> AsyncClientGenerator:
    """Async test client

    Usage::

    ```py
    from collections.abc import AsyncGenerator

    import pytest
    from asynctor.utils import client_manager
    from httpx import AsyncClient

    from main import app


    @pytest.fixture(scope='session')
    async def client() -> AsyncGenerator[AsyncClient, None]:
        async with client_manager(app) as c:
            yield c


    @pytest.fixture(scope="session")
    def anyio_backend():
        return "asyncio"


    @pytest.mark.anyio
    async def test_api(client: AsyncClient):
        response = await client.get("/")
        assert response.status_code == 200
    ```
    """
    import httpx

    if mount_lifespan:
        from asgi_lifespan import LifespanManager

        async with LifespanManager(app) as manager:
            transport = httpx.ASGITransport(manager.app)
            async with httpx.AsyncClient(
                timeout=timeout, transport=transport, base_url=base_url, **kwargs
            ) as c:
                yield c
    else:
        transport = httpx.ASGITransport(app)
        kwargs.update(transport=transport, base_url=base_url)
        async with httpx.AsyncClient(timeout=timeout, **kwargs) as c:
            yield c


class AsyncTestClient(AbstractAsyncContextManager):
    """Async test client for FastAPI

    :param app: a fastapi instance.
    :param mount_lifespan: if True, auto mount lifespan for app.
    :param base_url: scheme and host for the httpx AsyncClient.
    :param kwargs: other kwargs to pass to httpx AsyncClient.

    Usage::

    ... code-block:: python3

        import pytest
        from asynctor import AsyncTestClient, AsyncClientGenerator
        from httpx import AsyncClient

        from main import app

        @pytest.fixture(scope='session')
        async def client() -> AsyncClientGenerator:
            async with AsyncTestClient(app) as c:
                yield c

        @pytest.fixture(scope="session")
        def anyio_backend():
            return "asyncio"

        @pytest.mark.anyio
        async def test_api(client: AsyncClient):
            response = await client.get("/")
            assert response.status_code == 200

    """

    def __init__(
        self,
        app: FastAPI,
        mount_lifespan: bool = True,
        base_url: str = "http://test",
        timeout: int | float = 30,
        **kwargs,
    ) -> None:
        self._app = app
        self._mount_lifespan = mount_lifespan
        self._manager: LifespanManager | None = None
        self._client: AsyncClient | None = None
        self._base_url = base_url
        self._timeout = timeout
        self._kwargs = kwargs

    async def __aenter__(self) -> AsyncClient:
        from httpx import ASGITransport, AsyncClient

        app: ASGIApp = self._app
        if self._mount_lifespan:
            from asgi_lifespan import LifespanManager

            self._manager = manager = await LifespanManager(app).__aenter__()
            app = manager.app
        self._client = client = await AsyncClient(
            transport=ASGITransport(app),
            base_url=self._base_url,
            timeout=self._timeout,
            **self._kwargs,
        ).__aenter__()
        return client

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        if self._client:
            await self._client.__aexit__(exc_type, exc_value, traceback)
        if self._manager:
            await self._manager.__aexit__(exc_type, exc_value, traceback)


class AttrDict(dict):
    """Support get dict value by attribution

    NOTE: mypy will complaint attr-defined when using this class, e.g.:
    `error: "AttrDict" has no attribute "a"  [attr-defined]`

    Usage::
        >>> d = AttrDict({'a': 1, 'b': {'c': 2, 'd': {'e': 3}}})
        >>> d.a == d['a'] == 1
        True
        >>> d.b.c == d['b']['c'] == 2
        True
        >>> d.b.d == d['b']['d'] == {'e': 3}
        True
        >>> d.b.d.e == d['b']['d']['e'] == 3
        True
        >>> dd = AttrDict({'keys': 2}, items=1)
        >>> list(dd.items()) == [('keys', 2), ('items', 1)]
        True
        >>> dd['items'] == 1
        True
        >>> list(dd.keys()) == ['keys', 'items']
        True
        >>> dd['keys'] == 2
        True
        >>> d3 = AttrDict({b'a': 1})
        >>> d3[b'a'] == 1 and getattr(d3, 'a', None) is None
        True
    """

    def __init__(self, *args, **kw) -> None:
        super().__init__(*args, **kw)
        exclude = set(dir(self)) | set(self.__dict__)
        for k, v in self.items():
            if not isinstance(k, str) or k in exclude:
                continue
            if isinstance(v, dict):
                v = self.__class__(v)
            self.__dict__.setdefault(k, v)

    def __str__(self) -> str:
        return super().__repr__()

    def __repr__(self) -> str:
        return self.__class__.__name__ + "(" + super().__repr__() + ")"


def get_machine_ip() -> str:
    r"""Get IP of current machine by socket, if failed, return '127.0.0.1'

    Usage::
        >>> import re
        >>> my_ip = get_machine_ip()
        >>> bool(re.search(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', my_ip))
        True
        >>> inets = my_ip.split('.')
        >>> len(inets) == 4
        True
        >>> sum(map(lambda x: 0 <= int(x) <= 255, inets))
        4
    """
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.settimeout(0)
        try:
            # doesn't even have to be reachable
            s.connect(("10.254.254.254", 1))
            return s.getsockname()[0]
        except Exception:
            return "127.0.0.1"


def cache_attr(func: Callable[..., T]) -> Callable[..., T]:
    """Cache result to class attribute

    Usage::
    ```py
    class A:
        @classmethod
        @cache_attr
        def do_sth(cls):
            ...

    result = A.do_sth()
    ```

    Example::
        >>> import time
        >>> from datetime import datetime
        >>> class A:
        ...     @classmethod
        ...     @cache_attr
        ...     def now(cls) -> datetime:
        ...         return datetime.now()
        >>> now_a = A.now()
        >>> time.sleep(0.1)
        >>> now = datetime.now()
        >>> A.now() == now_a == getattr(A, '-cache-now')
        True
        >>> A.now() < now
        True

    """

    @functools.wraps(func)
    def run(cls) -> T:
        key = "-cache-" + getattr(func, "__name__", str(func))
        if hasattr(cls, key):
            return getattr(cls, key)
        res = func(cls)
        setattr(cls, key, res)
        return res

    return run


class Shell:
    """Run shell command by subprocess

    Usage::
        >>> import sys
        >>> s = Shell('python -V')
        >>> r = s.run()
        >>> r.returncode == s.call() == 0
        True
        >>> '{}.{}.{}'.format(*sys.version_info) in s.capture_output()
        True
        >>> code = "import sys;sys.exit(1)"
        >>> Shell(f'python -c {code!r}').call() == 1
        True
    """

    @staticmethod
    def shell_should_be_true(command: list[str] | str) -> bool:
        return bool(set(command) & {"|", ">", "&"})

    @classmethod
    def run_by_subprocess(cls, cmd: str, **kw) -> subprocess.CompletedProcess[str]:
        if (shell := kw.get("shell")) is None and cls.shell_should_be_true(cmd):
            kw["shell"] = shell = True
        command = cmd if shell else shlex.split(cmd)
        return subprocess.run(command, **kw)  # nosec

    @classmethod
    def run_and_echo(
        cls, cmd: str | list[str], *, dry: bool = False, verbose: bool = True, **kw: Any
    ) -> int:
        self = cls(cmd, **kw)
        if dry:
            print("-->", self.command)
            return 0
        return self.call(verbose=verbose)

    def __init__(self, command: list[str] | str, **kwargs) -> None:
        self._command = command
        self._kwargs = kwargs

    @property
    def command(self) -> str:
        if isinstance(self._command, str):
            return self._command
        return " ".join(repr(i) if " " in i else i for i in self._command)

    def run(
        self, kwargs: dict[str, Any] | None = None, *, verbose: bool = False, **kw: Any
    ) -> subprocess.CompletedProcess[str]:
        cmd = self.command
        if verbose:
            print("-->", cmd)
        if kwargs is None:
            kwargs = self._kwargs
        kwargs.update(kw)
        if kwargs.get("shell") is None and self.shell_should_be_true(self._command):
            kwargs["shell"] = True
        elif isinstance(self._command, list):
            return subprocess.run(self._command, **kwargs)  # nosec
        return self.run_by_subprocess(cmd, **kwargs)

    def call(self, *, verbose: bool = False) -> int:
        return self.run(verbose=verbose).returncode

    def capture_output(self, *, verbose: bool = False) -> str:
        kw = dict(self._kwargs, text=True, encoding="utf-8", capture_output=True)
        return self.run(kw, verbose=verbose).stdout


def load_bool(env: str, *, strict: bool = False) -> bool:
    match os.getenv(env):
        case None:
            return False
        case "1":
            return True
        case "0":
            return False
        case x if x.lower() in ("true", "yes", "on", "y"):
            return True
        case _ as x:
            if strict and x.lower() not in ("false", "no", "off", "n"):
                raise ValueError(f"Value of env {env!r} must be a bool (Got {x!r})")
            return False


class ExtendSyspath(AbstractContextManager):
    """Extend sys.path

    Example::

        from asynctor.utils import ExtendSyspath

        with ExtendSyspath(__file__):
            from app import __version__

    Or:
        from pathlib import Path

        with ExtendSyspath(BASE_DIR := Path(__file__).parent.parent):
            from app import __version__

    """

    def __init__(self, path: Path | str | None = None, rollback: bool = False) -> None:
        if path is None:
            path = Path()
        elif isinstance(path, str):
            path = Path(path)
        self.path: Path = path
        self._path = ""
        self.rollback = rollback

    def __enter__(self) -> Self:
        if (path := self.path).is_file():
            path = path.parent
        if (p := path.as_posix()) not in sys.path:
            self._path = p
            sys.path.append(p)
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        if self.rollback and self._path:
            try:
                index = sys.path.index(self._path)
            except IndexError:
                ...
            else:
                sys.path.pop(index)


def _test() -> None:  # pragma: no cover
    import doctest

    doctest.testmod(verbose=True)


if __name__ == "__main__":
    _test()
