from __future__ import annotations

import asyncio
import base64
import ipaddress
import numbers
import random
import re
import sys
import uuid
from collections import OrderedDict
from collections.abc import AsyncIterator, Iterable, Iterator, Mapping
from datetime import timedelta
from itertools import chain
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    TypeVar,
)

import aiofiles
import yarl
from async_timeout import timeout
from pydantic import BaseModel

# It is a bad practice to keep all "miscellaneous" stuffs
# into the single "utils" module.
# Let's categorize them by purpose and domain, and keep
# refactoring to use the proper module names.
from .asyncio import (  # for legacy imports  # noqa
    AsyncBarrier,
    cancel_tasks,
    current_loop,
    run_through,
)
from .defs import DEFAULT_FILE_IO_TIMEOUT
from .enum_extension import StringSetFlag  # for legacy imports  # noqa
from .exception import (
    BaseNFSMountCheckFailed,
    ExportPathNotFound,
    NFSTimeoutError,
    NFSUnexpectedError,
    ShowmountFailed,
    ShowmountNotFound,
    VolumeMountFailed,
    VolumeUnmountFailed,
)
from .files import AsyncFileWriter  # for legacy imports  # noqa
from .networking import curl, find_free_port  # for legacy imports  # noqa
from .types import BinarySize

if TYPE_CHECKING:
    from decimal import Decimal

    from aiofiles.threadpool.text import AsyncTextIOWrapper

KT = TypeVar("KT")
VT = TypeVar("VT")


def env_info() -> str:
    """
    Returns a string that contains the Python version and runtime path.
    """
    v = sys.version_info
    pyver = f"Python {v.major}.{v.minor}.{v.micro}"
    if v.releaselevel == "alpha":
        pyver += "a"
    if v.releaselevel == "beta":
        pyver += "b"
    if v.releaselevel == "candidate":
        pyver += "rc"
    if v.releaselevel != "final":
        pyver += str(v.serial)
    return f"{pyver} (env: {sys.prefix})"


def odict[KT, VT](*args: tuple[KT, VT]) -> OrderedDict[KT, VT]:
    """
    A short-hand for the constructor of OrderedDict.
    :code:`odict(('a',1), ('b',2))` is equivalent to
    :code:`OrderedDict([('a',1), ('b',2)])`.
    """
    return OrderedDict(args)


def dict2kvlist[KT, VT](o: Mapping[KT, VT]) -> Iterable[KT | VT]:
    """
    Serializes a dict-like object into a generator of the flatten list of
    repeating key-value pairs.  It is useful when using HMSET method in Redis.

    Example:

       >>> list(dict2kvlist({'a': 1, 'b': 2}))
       ['a', 1, 'b', 2]
    """
    return chain.from_iterable((k, v) for k, v in o.items())


def generate_uuid() -> str:
    u = uuid.uuid4()
    # Strip the last two padding characters because u always has fixed length.
    return base64.urlsafe_b64encode(u.bytes)[:-2].decode("ascii")


def get_random_seq(length: float, num_points: int, min_distance: float) -> Iterator[float]:
    """
    Generate a random sequence of numbers within the range [0, length]
    with the given number of points and the minimum distance between the points.

    Note that X ( = the minimum distance d x the number of points N) must be equivalent to or smaller than
    the length L + d to guarantee the the minimum distance between the points.
    If X == L + d, the points are always equally spaced with d.

    :return: An iterator over the generated sequence
    """
    if num_points * min_distance > length + min_distance:
        raise ValueError(
            "There are too many points or it has a too large distance which cannot be fit into the"
            " given length."
        )
    extra = length - (num_points - 1) * min_distance
    ro = [random.uniform(0, 1) for _ in range(num_points + 1)]
    sum_ro = sum(ro)
    rn = [extra * r / sum_ro for r in ro[0:num_points]]
    spacing = [min_distance + rn[i] for i in range(num_points)]
    cumulative_sum = 0.0
    for s in spacing:
        cumulative_sum += s
        yield cumulative_sum - min_distance


def pprint_with_type(
    data: dict[Any, Any] | list[Any] | set[Any],
    depth: int = 0,
) -> None:
    """
    A pprint-like function that explicitly prints out the types of all objects.
    It is useful to debug JSON serialization errors due to hidden str-compatible keys
    in a nested dict.
    """
    indent = "  " * depth
    match data:
        case dict():  # dict
            for k, v in data.items():
                if isinstance(v, dict):
                    print(f"{indent}{k!r} ({type(k)=}): {{...: ...}}")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                if isinstance(v, list):
                    print(f"{indent}{k!r} ({type(k)=}): [...]")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                if isinstance(v, set):
                    print(f"{indent}{k!r} ({type(k)=}): {{...}}")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                print(f"{indent}{k!r} ({type(k)=}): {v!r} ({type(v)=})")
        case list() | set():  # list, set
            for v in data:
                if isinstance(v, dict):
                    print(f"{indent}- {{...: ...}}")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                if isinstance(v, list):
                    print(f"{indent}- [...]")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                if isinstance(v, set):
                    print(f"{indent}- {{...}}")
                    pprint_with_type(v, depth=depth + 1)
                    continue
                print(f"{indent}- {v!r} ({type(v)=})")


def nmget(
    o: Mapping[str, Any],
    key_path: str,
    def_val: Any | None = None,
    path_delimiter: str = ".",
    null_as_default: bool = True,
) -> Any:
    """
    A short-hand for retrieving a value from nested mappings
    ("nested-mapping-get"). At each level it checks if the given "path"
    component in the given key exists and return the default value whenever
    fails.

    Example:
    >>> o = {'a':{'b':1}, 'x': None}
    >>> nmget(o, 'a', 0)
    {'b': 1}
    >>> nmget(o, 'a.b', 0)
    1
    >>> nmget(o, 'a/b', 0, '/')
    1
    >>> nmget(o, 'a.c', 0)
    0
    >>> nmget(o, 'x', 0)
    0
    >>> nmget(o, 'x', 0, null_as_default=False)
    None
    """
    pieces = key_path.split(path_delimiter)
    current: Any = o
    while pieces:
        p = pieces.pop(0)
        if current is None or p not in current:
            return def_val
        current = current[p]
    if current is None and null_as_default:
        return def_val
    return current


def readable_size_to_bytes(expr: Any) -> BinarySize | Decimal:
    if isinstance(expr, numbers.Integral):
        return BinarySize(expr)
    return BinarySize.from_str(expr)


def str_to_timedelta(tstr: str) -> timedelta:
    """
    Convert humanized timedelta string into a Python timedelta object.

    Example:
    >>> str_to_timedelta('30min')
    datetime.timedelta(seconds=1800)
    >>> str_to_timedelta('1d1hr')
    datetime.timedelta(days=1, seconds=3600)
    >>> str_to_timedelta('2hours 15min')
    datetime.timedelta(seconds=8100)
    >>> str_to_timedelta('20sec')
    datetime.timedelta(seconds=20)
    >>> str_to_timedelta('300')
    datetime.timedelta(seconds=300)
    >>> str_to_timedelta('-1day')
    datetime.timedelta(days=-1)
    """
    _rx = re.compile(
        r"(?P<sign>[+|-])?\s*"
        r"((?P<days>\d+(\.\d+)?)(d|day|days))?\s*"
        r"((?P<hours>\d+(\.\d+)?)(h|hr|hrs|hour|hours))?\s*"
        r"((?P<minutes>\d+(\.\d+)?)(m|min|mins|minute|minutes))?\s*"
        r"((?P<seconds>\d+(\.\d+)?)(s|sec|secs|second|seconds))?$"
    )
    match = _rx.match(tstr)
    if not match:
        try:
            return timedelta(seconds=float(tstr))  # consider bare number string as seconds
        except TypeError:
            pass
        raise ValueError("Invalid time expression")
    groups = match.groupdict()
    sign = groups.pop("sign", None)
    if set(groups.values()) == {None}:
        raise ValueError("Invalid time expression")
    params = {n: -float(t) if sign == "-" else float(t) for n, t in groups.items() if t}
    return timedelta(**params)


class FstabEntry:
    """
    Entry class represents a non-comment line on the `fstab` file.
    """

    def __init__(
        self, device: str, mountpoint: str, fstype: str, options: str | None, d: int = 0, p: int = 0
    ) -> None:
        self.device = device
        self.mountpoint = mountpoint
        self.fstype = fstype
        if not options:
            options = "defaults"
        self.options = options
        self.d = d
        self.p = p

    def __eq__(self, o: Any) -> bool:
        return str(self) == str(o)

    def __str__(self) -> str:
        return f"{self.device} {self.mountpoint} {self.fstype} {self.options} {self.d} {self.p}"


class Fstab:
    """
    Reader/writer for fstab file.
    Takes aiofile pointer for async I/O. It should be writable if add/remove
    operations are needed.

    NOTE: This class references Jorge Niedbalski R.'s gist snippet.
          We have been converted it to be compatible with Python 3
          and to support async I/O.
          (https://gist.github.com/niedbalski/507e974ed2d54a87ad37)
    """

    def __init__(self, fp: AsyncTextIOWrapper) -> None:
        self._fp = fp

    def _hydrate_entry(self, line: str) -> FstabEntry:
        parts = [x for x in line.strip("\n").split(" ") if x not in ("", None)]
        # Ensure we have at least 4 parts (device, mountpoint, fstype, options)
        # and convert the last two to integers if present
        device = parts[0] if len(parts) > 0 else ""
        mountpoint = parts[1] if len(parts) > 1 else ""
        fstype = parts[2] if len(parts) > 2 else ""
        options = parts[3] if len(parts) > 3 else None
        d = int(parts[4]) if len(parts) > 4 else 0
        p = int(parts[5]) if len(parts) > 5 else 0
        return FstabEntry(device, mountpoint, fstype, options, d, p)

    async def get_entries(self) -> AsyncIterator[FstabEntry]:
        await self._fp.seek(0)
        for line in await self._fp.readlines():
            try:
                line = line.strip()
                if not line.startswith("#"):
                    yield self._hydrate_entry(line)
            except TypeError:
                pass

    async def get_entry_by_attr(self, attr: str, value: Any) -> FstabEntry | None:
        async for entry in self.get_entries():
            e_attr = getattr(entry, attr)
            if e_attr == value:
                return entry
        return None

    async def add_entry(self, entry: FstabEntry) -> None:
        if await self.get_entry_by_attr("device", entry.device):
            return
        await self._fp.write(str(entry) + "\n")
        await self._fp.truncate()

    async def add(
        self,
        device: str,
        mountpoint: str,
        fstype: str,
        options: str | None = None,
        d: int = 0,
        p: int = 0,
    ) -> None:
        return await self.add_entry(FstabEntry(device, mountpoint, fstype, options, d, p))

    async def remove_entry(self, entry: FstabEntry) -> bool:
        await self._fp.seek(0)
        lines = await self._fp.readlines()
        line_no: int | None = None
        for index, line in enumerate(lines):
            if not line.strip().startswith("#"):
                try:
                    if self._hydrate_entry(line) == entry:
                        line_no = index
                        break
                except TypeError:
                    pass
        else:
            return False
        if line_no is None:
            raise RuntimeError("Failed to find entry in fstab")
        del lines[line_no]
        await self._fp.seek(0)
        await self._fp.write("".join(lines))
        await self._fp.truncate()
        return True

    async def remove_by_mountpoint(self, mountpoint: str) -> bool:
        entry = await self.get_entry_by_attr("mountpoint", mountpoint)
        if entry:
            return await self.remove_entry(entry)
        return False


async def check_nfs_remote_server(
    server: str,
    export_path: str,
) -> None:
    """
    Check if NFS export is available on the remote server.

    Args:
        server: NFS server hostname or IP address
        export_path: Export directory path to check (e.g., '/export')
    """
    try:
        # Execute showmount command to list exports
        process = await asyncio.create_subprocess_exec(
            "showmount",
            "-e",
            server,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

        # Wait for command completion with timeout
        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10.0)

        # Check if command succeeded
        if process.returncode != 0:
            error_msg = stderr.decode().strip()
            raise ShowmountFailed(f"showmount command failed: {error_msg}")

        # Parse output to find the export path
        output = stdout.decode().strip()
        lines = output.split("\n")

        # Skip header line (usually "Export list for server:")
        export_lines = [line for line in lines[1:] if line.strip()]

        # Check if the requested export path exists
        for line in export_lines:
            # Format is typically: /export  192.168.1.0/24
            # or: /export  *
            parts = line.split()
            if parts and parts[0] == export_path:
                # Export path found
                return

        # Export path not found
        raise ExportPathNotFound(f"Export '{export_path}' not found on server '{server}'.")
    except BaseNFSMountCheckFailed as e:
        raise e from None

    except TimeoutError as e:
        raise NFSTimeoutError(f"Timeout: No response from {server} within 10 seconds") from e

    except FileNotFoundError as e:
        raise ShowmountNotFound("showmount command not found. Install nfs-common package.") from e

    except Exception as e:
        raise NFSUnexpectedError(f"Unexpected error: {e!s}") from e


async def mount(
    mount_path: str,
    fs_location: str,
    fs_type: str = "nfs",
    cmd_options: str | None = None,
    edit_fstab: bool = False,
    fstab_path: str | None = None,
    mount_prefix: str | None = None,
) -> bool:
    if mount_prefix is None:
        mount_prefix = "/"
    if fstab_path is None:
        fstab_path = "/etc/fstab"
    mountpoint = Path(mount_prefix) / mount_path
    if mountpoint.is_mount():
        return False
    try:
        mountpoint.mkdir(exist_ok=True)
    except OSError as e:
        raise VolumeMountFailed(f"Failed to create mountpoint {mountpoint}. (e: {e})") from e
    if cmd_options is not None:
        cmd = [
            "mount",
            "-t",
            fs_type,
            "-o",
            cmd_options,
            fs_location,
            str(mountpoint),
        ]
    else:
        cmd = ["mount", "-t", fs_type, fs_location, str(mountpoint)]
    proc = await asyncio.create_subprocess_exec(
        *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
    )
    raw_out, raw_err = await proc.communicate()
    raw_out.decode("utf8")
    err = raw_err.decode("utf8")
    await proc.wait()
    if err:
        raise VolumeMountFailed(f"Failed to mount {fs_location} on {mountpoint}. (e: {err})")
    if edit_fstab:
        async with aiofiles.open(fstab_path, mode="r+") as fp:
            fstab = Fstab(fp)
            await fstab.add(
                fs_location,
                str(mountpoint),
                fs_type,
                cmd_options,
            )
    return True


async def umount(
    mount_path: str,
    mount_prefix: str | None = None,
    edit_fstab: bool = False,
    fstab_path: str | None = None,
    rmdir_if_empty: bool = False,
    *,
    timeout_sec: float | None = DEFAULT_FILE_IO_TIMEOUT,
) -> bool:
    if mount_prefix is None:
        mount_prefix = "/"
    if fstab_path is None:
        fstab_path = "/etc/fstab"
    mountpoint = Path(mount_prefix) / mount_path
    if Path(mount_prefix) == mountpoint:
        raise ValueError("mountpoint cannot be the same as mount_prefix")
    if not mountpoint.is_mount():
        return False
    try:
        with timeout(timeout_sec):
            proc = await asyncio.create_subprocess_exec(
                *[
                    "umount",
                    str(mountpoint),
                ],
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            raw_out, raw_err = await proc.communicate()
            raw_out.decode("utf8")
            err = raw_err.decode("utf8")
            await proc.wait()
    except TimeoutError as e:
        raise VolumeUnmountFailed(
            f"Failed to umount {mountpoint}. Raise timeout ({timeout_sec}sec). "
            "The process may be hanging in state D, which needs to be checked."
        ) from e
    if err:
        raise VolumeUnmountFailed(f"Failed to umount {mountpoint}")
    if rmdir_if_empty:
        try:
            mountpoint.rmdir()  # delete directory if empty
        except OSError:
            pass
    if edit_fstab:
        async with aiofiles.open(fstab_path, mode="r+") as fp:
            fstab = Fstab(fp)
            await fstab.remove_by_mountpoint(str(mountpoint))
    return True


async def chown(path: Path | str, uid_gid: str, mount_prefix: str | Path | None = None) -> None:
    mounted = Path(mount_prefix or "/", path)
    await asyncio.create_subprocess_exec(
        "chown",
        uid_gid,
        str(mounted),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )


def is_ip_address_format(str: str) -> bool:
    try:
        url = yarl.URL("//" + str)
        return bool(url.host and ipaddress.ip_address(url.host))
    except ValueError:
        return False


def join_non_empty(*args: str | None, sep: str) -> str:
    """
    Joins non-empty strings from the given arguments using the specified separator.
    """
    filtered_args = [arg for arg in args if arg]
    return sep.join(filtered_args)


def b64encode(s: str) -> str:
    """
    base64 encoding method of graphql_relay.
    Use it in components where the graphql_relay package is unavailable.
    """
    b: bytes = s.encode("utf-8") if isinstance(s, str) else s
    return base64.b64encode(b).decode("ascii")


T = TypeVar("T", bound=BaseModel)


def _deep_merge_lists(base: list[Any], override: list[Any]) -> list[Any]:
    """
    Merge two lists by index. If both elements at the same index are Mappings,
    deep merge them. If both are lists, recursively merge them.
    Otherwise, override element takes precedence.
    """
    result: list[Any] = []
    max_len = max(len(base), len(override))
    for i in range(max_len):
        if i >= len(base):
            result.append(override[i])
        elif i >= len(override):
            result.append(base[i])
        elif isinstance(base[i], Mapping) and isinstance(override[i], Mapping):
            result.append(deep_merge(base[i], override[i]))
        elif isinstance(base[i], list) and isinstance(override[i], list):
            result.append(_deep_merge_lists(base[i], override[i]))
        elif override[i] is not None:
            result.append(override[i])
        else:
            result.append(base[i])
    return result


def deep_merge(*args: Mapping[str, Any]) -> Mapping[str, Any]:
    """
    Recursively merge any number of mappings.
    Later mappings override earlier ones on key conflicts.
    For lists, merge elements at the same index if both are Mappings.

    Example
    -------
    >>> deep_merge({"a": 1, "b": {"x": 1}},
    ...            {"b": {"y": 2}, "c": 3},
    ...            {"b": {"x": 10}})
    {'a': 1, 'b': {'x': 10, 'y': 2}, 'c': 3}

    >>> deep_merge({"items": [{"name": "a", "value": 1}]},
    ...            {"items": [{"name": "a", "extra": 2}]})
    {'items': [{'name': 'a', 'value': 1, 'extra': 2}]}
    """
    merged: dict[str, Any] = {}
    for m in args:
        for k, vb in m.items():
            va = merged.get(k)
            if isinstance(va, Mapping) and isinstance(vb, Mapping):
                merged[k] = deep_merge(va, vb)
            elif isinstance(va, list) and isinstance(vb, list):
                merged[k] = _deep_merge_lists(va, vb)
            elif vb is not None:
                merged[k] = vb
            else:
                merged[k] = va

    return merged


def addr_to_hostport_pair(addr: str) -> tuple[str, int]:
    """
    Convert a Redis address string to a host-port pair.
    """
    parts = addr.split(":")
    if len(parts) == 1:
        raise ValueError(f"Invalid Redis address format: {addr}")
    host = parts[0]
    port = int(parts[1])
    return host, port
