import re
from typing import TYPE_CHECKING, Any, BinaryIO, IO
import os
from pathlib import Path
import sys
import base64
import hashlib
import secrets
from contextlib import contextmanager
from functools import wraps
from os import environ
from types import ModuleType
import traceback
import zlib
import tarfile
from importlib.metadata import version as pkg_version
from packaging.version import Version

if TYPE_CHECKING:
    from dlt.common.libs.sqlglot import TSqlGlotDialect
else:
    TSqlGlotDialect = Any

from typing import (
    Any,
    Callable,
    ContextManager,
    Dict,
    MutableMapping,
    Iterator,
    Generator,
    Optional,
    Sequence,
    Set,
    Tuple,
    Type,
    TypeVar,
    Mapping,
    List,
    Union,
    Iterable,
    IO,
    cast,
)

from dlt.common.exceptions import (
    DltException,
    ExceptionTrace,
    TerminalException,
    DependencyVersionException,
    ValueErrorWithKnownValues,
)
from dlt.common.typing import AnyFun, StrAny, DictStrAny, StrStr, TAny, TFun, Generic
from dlt.common.warnings import Dlt100DeprecationWarning, deprecated


T = TypeVar("T")
TObj = TypeVar("TObj", bound=object)
TDict = TypeVar("TDict", bound=MutableMapping[Any, Any])

TKey = TypeVar("TKey")
TValue = TypeVar("TValue")

# row counts
RowCounts = Dict[str, int]


def chunks(iterable: Iterable[T], n: int) -> Iterator[Sequence[T]]:
    it = iter(iterable)
    while True:
        chunk = list()
        try:
            for _ in range(n):
                chunk.append(next(it))
        except StopIteration:
            if chunk:
                yield chunk
            break
        yield chunk


def uniq_id(len_: int = 16) -> str:
    """Returns a hex encoded crypto-grade string of random bytes with desired len_"""
    return secrets.token_hex(len_)


def uniq_id_base64(len_: int = 16) -> str:
    """Returns a base64 encoded crypto-grade string of random bytes with desired len_"""
    return base64.b64encode(secrets.token_bytes(len_)).decode("ascii").rstrip("=")


def many_uniq_ids_base64(n_ids: int, len_: int = 16) -> List[str]:
    """Generate `n_ids` base64 encoded crypto-grade strings of random bytes with desired len_.
    This is more performant than calling `uniq_id_base64` multiple times.
    """
    random_bytes = secrets.token_bytes(n_ids * len_)
    encode = base64.b64encode
    return [
        encode(random_bytes[i : i + len_]).decode("ascii").rstrip("=")
        for i in range(0, n_ids * len_, len_)
    ]


def digest128(v: str, len_: int = 15) -> str:
    """Returns a base64 encoded shake128 hash of str `v` with digest of length `len_` (default: 15 bytes = 20 characters length)"""
    return (
        base64.b64encode(hashlib.shake_128(v.encode("utf-8")).digest(len_))
        .decode("ascii")
        .rstrip("=")
    )


def digest128b(v: bytes, len_: int = 15) -> str:
    """Returns a base64 encoded shake128 hash of bytes `v` with digest of length `len_` (default: 15 bytes = 20 characters length)"""
    enc_v = base64.b64encode(hashlib.shake_128(v).digest(len_)).decode("ascii")
    return enc_v.rstrip("=")


def digest256(v: str) -> str:
    digest = hashlib.sha3_256(v.encode("utf-8")).digest()
    return base64.b64encode(digest).decode("ascii")


def digest256_file_stream(stream: BinaryIO, chunk_size: int = 4096) -> str:
    """Returns a base64 encoded sha3_256 hash of a binary stream"""
    stream.seek(0)
    hash_obj = hashlib.sha3_256()
    while chunk := stream.read(chunk_size):
        hash_obj.update(chunk)
    digest = hash_obj.digest()
    return base64.b64encode(digest).decode("ascii")


def digest256_tar_stream(
    stream: IO[bytes],
    filter_file_names: Callable[[str], bool] = lambda x: True,
    chunk_size: int = 8192,
) -> Tuple[str, List[str]]:
    """Calculates hash and collects file names from tar archive in a single pass.

    Hashes only file names and file contents of filtered members, ignoring timestamps
    and other tar metadata. Members are sorted by name before hashing for consistency.
    Operates entirely in-memory to prevent leakage of sensitive data.

    Args:
        stream: Binary stream containing the tar archive
        filter_file_names: Callable that returns True for members to include in hash
            and file names list. Default includes all members. Use this to exclude
            metadata files (e.g., manifest.yaml) from the hash calculation.
        chunk_size: Size of chunks to read when hashing file contents. Default 8192.

    Returns:
        tuple: (content_hash, file_names)
    """
    stream.seek(0)
    hash_obj = hashlib.sha3_256()
    file_names = []

    with tarfile.open(fileobj=stream, mode="r:*") as tar:
        members = sorted(tar.getmembers(), key=lambda m: m.name)

        for member in members:
            if not filter_file_names(member.name):
                continue

            hash_obj.update(member.name.encode())
            if member.isfile():
                file_names.append(member.name)
                f = tar.extractfile(member)
                if f:
                    while chunk := f.read(chunk_size):
                        hash_obj.update(chunk)

    digest = hash_obj.digest()
    content_hash = base64.b64encode(digest).decode("ascii")

    return content_hash, file_names


def str2bool(v: str) -> bool:
    if isinstance(v, bool):
        return v
    if v.lower() in ("yes", "true", "t", "y", "1"):
        return True
    elif v.lower() in ("no", "false", "f", "n", "0"):
        return False
    else:
        raise ValueErrorWithKnownValues(
            "v", v, [True, "yes", "true", "t", "y", 1, False, "no", "false", "f", "n", "0"]
        )


# def flatten_list_of_dicts(dicts: Sequence[StrAny]) -> StrAny:
#     """
#     Transforms a list of objects [{K: {...}}, {L: {....}}, ...] -> {K: {...}, L: {...}...}
#     """
#     o: DictStrAny = {}
#     for d in dicts:
#         for k,v in d.items():
#             if k in o:
#                 raise KeyError(f"Cannot flatten with duplicate key {k}")
#             o[k] = v
#     return o


def flatten_list_of_str_or_dicts(seq: Sequence[Union[StrAny, str]]) -> DictStrAny:
    """
    Transforms a list of objects or strings [{K: {...}}, L, ...] -> {K: {...}, L: None, ...}
    """
    o: DictStrAny = {}
    for e in seq:
        if isinstance(e, dict):
            for k, v in e.items():
                if k in o:
                    raise KeyError(f"Failed to flatten because of duplicate key `{k}`")
                o[k] = v
        else:
            key = str(e)
            if key in o:
                raise KeyError(f"Failed to flatten because of duplicate key `{key}`")
            o[key] = None
    return o


def flatten_list_or_items(
    _iter: Union[Iterable[TAny], Iterable[List[TAny]]]
) -> Generator[TAny, None, None]:
    for items in _iter:
        if isinstance(items, List):
            yield from items
        else:
            yield items


def concat_strings_with_limit(strings: List[str], separator: str, limit: int) -> Iterator[str]:
    """
    Generator function to concatenate strings.

    The function takes a list of strings and concatenates them into a single string such that the length of each
    concatenated string does not exceed a specified limit. It yields each concatenated string as it is created.
    The strings are separated by a specified separator.

    Args:
        strings (List[str]): The list of strings to be concatenated.
        separator (str): The separator to use between strings. Defaults to a single space.
        limit (int): The maximum length for each concatenated string.

    Yields:
        Generator[str, None, None]: A generator that yields each concatenated string.
    """

    if not strings:
        return

    current_length = len(strings[0])
    start = 0
    sep_len = len(separator)

    for i in range(1, len(strings)):
        if (
            current_length + len(strings[i]) + sep_len > limit
        ):  # accounts for the length of separator
            yield separator.join(strings[start:i])
            start = i
            current_length = len(strings[i])
        else:
            current_length += len(strings[i]) + sep_len  # accounts for the length of separator

    yield separator.join(strings[start:])


def graph_edges_to_nodes(
    edges: Sequence[Tuple[TAny, TAny]], directed: bool = True
) -> Dict[TAny, Set[TAny]]:
    """Converts a directed graph represented as a sequence of edges to a graph represented as a mapping from nodes a set of connected nodes.

    Isolated nodes are represented as edges to itself. If `directed` is `False`, each edge is duplicated but going in opposite direction.
    """
    graph: Dict[TAny, Set[TAny]] = {}
    for u, v in edges:
        if u not in graph:
            graph[u] = set()
        if v not in graph:
            graph[v] = set()
        if v != u:
            graph[u].add(v)
            if not directed:
                graph[v].add(u)

    return graph


def graph_find_scc_nodes(undag: Dict[TAny, Set[TAny]]) -> List[Set[TAny]]:
    """Finds and returns a list of sets of nodes in strongly connected components of a `undag` which is undirected

    To obtain undirected graph from edges use `graph_edges_to_nodes` function with `directed` argument `False`.
    """
    visited: Set[TAny] = set()
    components: List[Set[TAny]] = []

    def dfs(node: TAny, current_component: Set[TAny]) -> None:
        if node not in visited:
            visited.add(node)
            current_component.add(node)
            for neighbor in undag[node]:
                dfs(neighbor, current_component)

    for node in undag:
        if node not in visited:
            component: Set[TAny] = set()
            dfs(node, component)
            components.append(component)

    return components


def filter_env_vars(envs: List[str]) -> StrStr:
    return {k.lower(): environ[k] for k in envs if k in environ}


def update_dict_with_prune(dest: DictStrAny, update: StrAny) -> None:
    """Updates values that are both in `dest` and `update` and deletes `dest` values that are None in `update`"""
    for k, v in update.items():
        if v is not None:
            dest[k] = v
        elif k in dest:
            del dest[k]


def update_dict_nested(dst: TDict, src: TDict, copy_src_dicts: bool = False) -> TDict:
    """Merges `src` into `dst` key wise. Does not recur into lists. Values in `src` overwrite `dst` if both keys exit.
    Only `dict` and its subclasses are updated recursively. With `copy_src_dicts`, dict key:values will be deep copied,
    otherwise, both dst and src will keep the same references.
    """

    for key in src:
        src_val = src[key]
        if key in dst:
            dst_val = dst[key]
            if isinstance(src_val, dict) and isinstance(dst_val, dict):
                # If the key for both `dst` and `src` are both Mapping types (e.g. dict), then recurse.
                update_dict_nested(dst_val, src_val, copy_src_dicts=copy_src_dicts)
                continue

        if copy_src_dicts and isinstance(src_val, dict):
            dst[key] = update_dict_nested({}, src_val, True)
        else:
            dst[key] = src_val
    return dst


def clone_dict_nested(src: TDict) -> TDict:
    """Clones `src` structure descending into nested dicts. Does not descend into mappings that are not dicts ie. specs instances.
    Compared to `deepcopy` does not clone any other objects. Uses `update_dict_nested` internally
    """
    return update_dict_nested({}, src, copy_src_dicts=True)  # type: ignore[return-value]


def map_nested_values_in_place(
    func: AnyFun,
    _nested: TAny,
    *args: Any,
    **kwargs: Any,
) -> TAny:
    """Applies `func` to all values in `_dict` recursively, replacing elements in nested dictionaries and lists in place.
    Additional `*args` and `**kwargs` are passed to `func`.
    """
    if isinstance(_nested, tuple):
        if hasattr(_nested, "_asdict"):
            _nested = _nested._asdict()
        else:
            _nested = list(_nested)  # type: ignore

    if isinstance(_nested, dict):
        for k, v in _nested.items():
            if isinstance(v, (dict, list, tuple)):
                _nested[k] = map_nested_values_in_place(func, v, *args, **kwargs)
            else:
                _nested[k] = func(v, *args, **kwargs)
    elif isinstance(_nested, list):
        for idx, _l in enumerate(_nested):
            if isinstance(_l, (dict, list, tuple)):
                _nested[idx] = map_nested_values_in_place(func, _l, *args, **kwargs)
            else:
                _nested[idx] = func(_l, *args, **kwargs)
    else:
        raise ValueError(_nested, "Not a nested type")
    return _nested


# keep old name for backwards compatibility
map_nested_in_place = deprecated(
    "Use `map_nested_values_in_place` instead.", category=Dlt100DeprecationWarning
)(map_nested_values_in_place)


def map_nested_keys_in_place(
    func: AnyFun,
    _nested: TAny,
    *args: Any,
    **kwargs: Any,
) -> TAny:
    """Applies `func` to all keys in `_dict` recursively, replacing elements in nested dictionaries and lists in place.
    Additional `*args` and `**kwargs` are passed to `func`. List indexes will remain untouched.
    """
    if isinstance(_nested, tuple):
        if hasattr(_nested, "_asdict"):
            _nested = _nested._asdict()
        else:
            _nested = list(_nested)  # type: ignore

    if isinstance(_nested, dict):
        # NOTE: to modify the dictionary in place, exhaust the iterator into a list before iterating over it
        for k, v in list(_nested.items()):
            _nested.pop(k)
            k = func(k, *args, **kwargs)
            if isinstance(v, (dict, list, tuple)):
                _nested[k] = map_nested_keys_in_place(func, v, *args, **kwargs)
            else:
                _nested[k] = v
    elif isinstance(_nested, list):
        for idx, _l in enumerate(_nested):
            if isinstance(_l, (dict, list, tuple)):
                _nested[idx] = map_nested_keys_in_place(func, _l, *args, **kwargs)
    else:
        raise ValueError(_nested, "Not a nested type")
    return _nested


def is_interactive() -> bool:
    """
    Determine if the current environment is interactive.

    Returns:
        bool: True if interactive (e.g., REPL, IPython, Jupyter Notebook), False if running as a script.
    """
    import __main__ as main

    # When running as a script, the __main__ module has a __file__ attribute.
    # In an interactive environment, the __file__ attribute is absent.
    return not hasattr(main, "__file__")


def dict_remove_nones_in_place(d: Dict[Any, Any]) -> Dict[Any, Any]:
    for k in list(d.keys()):
        if d[k] is None:
            del d[k]
    return d


def simple_repr(object_name: str, **kwargs: Any) -> str:
    """Create a simple string representation of an object.

    For example:

        s = simple_repr("Resource", name="my_resource", table_name="my_table")
        print(s)
        # "Resource(name='my_resource', table_name='my_table')"

    """
    args = [f"{k}={v.__repr__()}" for k, v in kwargs.items()]
    return f"<{object_name}({', '.join(args)})>"


@contextmanager
def custom_environ(env: StrStr) -> Iterator[None]:
    """Temporarily set environment variables inside the context manager and
    fully restore previous environment afterwards
    """
    original_env = {key: os.getenv(key) for key in env}
    os.environ.update(env)
    try:
        yield
    finally:
        for key, value in original_env.items():
            if value is None:
                del os.environ[key]
            else:
                os.environ[key] = value


def with_custom_environ(f: TFun) -> TFun:
    @wraps(f)
    def _wrap(*args: Any, **kwargs: Any) -> Any:
        saved_environ = os.environ.copy()
        try:
            return f(*args, **kwargs)
        finally:
            os.environ.clear()
            os.environ.update(saved_environ)

    return _wrap  # type: ignore


def encoding_for_mode(mode: str) -> Optional[str]:
    if "b" in mode:
        return None
    else:
        return "utf-8"


def main_module_file_path() -> str:
    if len(sys.argv) > 0 and os.path.isfile(sys.argv[0]):
        return str(Path(sys.argv[0]))
    return None


@contextmanager
def set_working_dir(path: str) -> Iterator[str]:
    curr_dir = os.path.abspath(os.getcwd())
    try:
        if path:
            os.chdir(path)
        yield path
    finally:
        os.chdir(curr_dir)


@contextmanager
def multi_context_manager(managers: Sequence[ContextManager[Any]]) -> Iterator[Any]:
    """A context manager holding several other context managers. Enters and exists all of them. Yields from the last in the list"""
    try:
        rv: Any = None
        for manager in managers:
            rv = manager.__enter__()
        yield rv
    except Exception as ex:
        # release context manager
        for manager in managers:
            if isinstance(ex, StopIteration):
                manager.__exit__(None, None, None)
            else:
                manager.__exit__(type(ex), ex, None)
        raise
    else:
        for manager in managers:
            manager.__exit__(None, None, None)


def get_callable_name(f: AnyFun, name_attr: str = "__name__") -> Optional[str]:
    # check first if __name__ is present directly (function), if not then look for type name
    name: str = getattr(f, name_attr, None)
    if not name:
        name = getattr(f.__class__, name_attr, None)
    return name


def is_inner_callable(f: AnyFun) -> bool:
    """Checks if f is defined within other function"""
    # inner functions have full nesting path in their qualname
    return "<locals>" in get_callable_name(f, name_attr="__qualname__")


def get_full_obj_class_name(obj: Any) -> str:
    cls = obj.__class__
    module = cls.__module__
    # exclude 'builtins' for built-in types.
    if module is None or module == "builtins":
        return cls.__name__  #  type: ignore[no-any-return]
    return module + "." + cls.__name__  #  type: ignore[no-any-return]


def get_full_callable_name(f: AnyFun) -> str:
    module = f.__module__
    name = get_callable_name(f, name_attr="__qualname__")
    # exclude 'builtins' for built-in types.
    if module is None or module == "builtins":
        return name
    return module + "." + name


def obfuscate_pseudo_secret(pseudo_secret: str, pseudo_key: bytes) -> str:
    return base64.b64encode(
        bytes([_a ^ _b for _a, _b in zip(pseudo_secret.encode("utf-8"), pseudo_key * 250)])
    ).decode()


def reveal_pseudo_secret(obfuscated_secret: str, pseudo_key: bytes) -> str:
    return bytes(
        [
            _a ^ _b
            for _a, _b in zip(
                base64.b64decode(obfuscated_secret.encode("ascii"), validate=True), pseudo_key * 250
            )
        ]
    ).decode("utf-8")


def get_module_name(m: ModuleType) -> str:
    """Gets module name from module with a fallback for executing module __main__"""
    if m.__name__ == "__main__" and hasattr(m, "__file__"):
        module_file = os.path.basename(m.__file__)
        module_name, _ = os.path.splitext(module_file)
        return module_name
    return m.__name__.split(".")[-1]


def derives_from_class_of_name(o: object, name: str) -> bool:
    """Checks if object o has class of name in its derivation tree"""
    mro = type.mro(type(o))
    return any(t.__name__ == name for t in mro)


def compressed_b64encode(value: bytes) -> str:
    """Compress and b64 encode the given bytestring"""
    return base64.b64encode(zlib.compress(value, level=9)).decode("ascii")


def compressed_b64decode(value: str) -> bytes:
    """Decode a bytestring encoded with `compressed_b64encode`"""
    value_bytes = base64.b64decode(value, validate=True)
    return zlib.decompress(value_bytes)


def identity(x: TAny) -> TAny:
    return x


def increase_row_count(row_counts: RowCounts, counter_name: str, count: int) -> None:
    row_counts[counter_name] = row_counts.get(counter_name, 0) + count


def merge_row_counts(row_counts_1: RowCounts, row_counts_2: RowCounts) -> None:
    """merges row counts_2 into row_counts_1"""
    # only keys present in row_counts_2 are modifed
    for counter_name in row_counts_2.keys():
        row_counts_1[counter_name] = row_counts_1.get(counter_name, 0) + row_counts_2[counter_name]


def extend_list_deduplicated(
    original_list: List[Any],
    extending_list: Iterable[Any],
    normalize_f: Callable[[str], str] = str.__call__,
) -> List[Any]:
    """extends the first list by the second, but does not add duplicates"""
    list_keys = set(normalize_f(s) for s in original_list)
    for item in extending_list:
        if normalize_f(item) not in list_keys:
            original_list.append(item)
    return original_list


@contextmanager
def maybe_context(manager: ContextManager[TAny]) -> Iterator[TAny]:
    """Allows context manager `manager` to be None by creating dummy context. Otherwise `manager` is used"""
    if manager is None:
        yield None
    else:
        with manager as ctx:
            yield ctx


def without_none(d: Mapping[TKey, Optional[TValue]]) -> Mapping[TKey, TValue]:
    """Return a new dict with all `None` values removed"""
    return {k: v for k, v in d.items() if v is not None}


def exclude_keys(mapping: Mapping[str, Any], keys: Iterable[str]) -> Dict[str, Any]:
    """Create a new dictionary from the input mapping, excluding specified keys.

    Args:
        mapping (Mapping[str, Any]): The input mapping from which keys will be excluded.
        keys (Iterable[str]): The keys to exclude.

    Returns:
        Dict[str, Any]: A new dictionary containing all key-value pairs from the original
                        mapping except those with keys specified in `keys`.
    """
    return {k: v for k, v in mapping.items() if k not in keys}


def get_exception_trace(exc: BaseException) -> ExceptionTrace:
    """Get exception trace and additional information for DltException(s)"""
    trace: ExceptionTrace = {"message": str(exc), "exception_type": get_full_obj_class_name(exc)}
    if exc.__traceback__:
        tb_extract = traceback.extract_tb(exc.__traceback__)
        trace["stack_trace"] = traceback.format_list(tb_extract)
    trace["is_terminal"] = isinstance(exc, TerminalException)

    # get attrs and other props
    if isinstance(exc, DltException):
        if exc.__doc__:
            trace["docstring"] = exc.__doc__
        attrs = exc.attrs()
        str_attrs = {}
        for k, v in attrs.items():
            if v is None:
                continue
            try:
                from dlt.common.json import json

                # must be json serializable, other attrs are skipped
                # TODO: be more picky re. what is serialized. exceptions contain data items and
                #  other value that should not be serialized
                if not isinstance(v, str):
                    json.dumps(v)
                str_attrs[k] = v
            except Exception:
                continue
            # extract special attrs
            if k in ["load_id", "pipeline_name", "source_name", "resource_name", "job_id"]:
                trace[k] = v  # type: ignore[literal-required]

        trace["exception_attrs"] = str_attrs
    return trace


def get_exception_trace_chain(
    exc: BaseException, traces: List[ExceptionTrace] = None, seen: Set[int] = None
) -> List[ExceptionTrace]:
    """Get traces for exception chain. The function will recursively visit all __cause__ and __context__ exceptions. The top level
    exception trace is first on the list
    """
    traces = traces or []
    seen = seen or set()
    # prevent cycles
    if id(exc) in seen:
        return traces
    seen.add(id(exc))
    traces.append(get_exception_trace(exc))
    if exc.__cause__:
        return get_exception_trace_chain(exc.__cause__, traces, seen)
    elif exc.__context__:
        return get_exception_trace_chain(exc.__context__, traces, seen)
    return traces


def group_dict_of_lists(input_dict: Dict[str, List[TAny]]) -> List[Dict[str, TAny]]:
    """Decomposes a dictionary with list values into a list of dictionaries with unique keys.

    This function takes an input dictionary where each key maps to a list of objects.
    It returns a list of dictionaries, each containing at most one object per key.
    The goal is to ensure that no two objects with the same key appear in the same dictionary.

    Parameters:
        input_dict (Dict[str, List[Any]]): A dictionary with string keys and list of objects as values.

    Returns:
        List[Dict[str, Any]]: A list of dictionaries, each with unique keys and single objects.
    """
    max_length = max(len(v) for v in input_dict.values())
    list_of_dicts: List[Dict[str, Any]] = [{} for _ in range(max_length)]
    for name, value_list in input_dict.items():
        for idx, obj in enumerate(value_list):
            list_of_dicts[idx][name] = obj
    return list_of_dicts


def order_deduped(lst: List[Any]) -> List[Any]:
    """Returns deduplicated list preserving order of input elements.

    Only works for lists with hashable elements.
    """
    return list(dict.fromkeys(lst))


def assert_min_pkg_version(pkg_name: str, version: str, msg: str = "") -> None:
    version_found = pkg_version(pkg_name)
    if Version(version_found) < Version(version):
        raise DependencyVersionException(
            pkg_name=pkg_name,
            version_found=version_found,
            version_required=">=" + version,
            appendix=msg,
        )


def make_defunct_class(cls: TObj) -> Type[TObj]:
    class DefunctClass(cls.__class__):  # type: ignore[name-defined]
        """A defunct class to replace __class__ when we want to destroy current instance"""

        def __getattribute__(self, name: str) -> Any:
            if name == "__class__":
                # Allow access to __class__
                return object.__getattribute__(self, name)
            else:
                raise RuntimeError("This instance has been dropped and cannot be used anymore.")

    return DefunctClass


class classlocal(Generic[TAny]):
    """A descriptor that is *only* visible on the class that defines it.

    Any attempt to read it from a derived class raises AttributeError,
    so `hasattr(Sub, name)` is *False* unless Sub defines its own copy.
    """

    def __init__(self, value: TAny, owner: type[Any]) -> None:
        self._value: TAny = value
        self._owner: type[Any] = owner

    def __get__(self, obj: Any, cls: type[Any] = None) -> TAny:
        # `cls` is the *class* through which the attribute lookup happened
        # access from the defining class
        if cls is self._owner:
            return self._value
        # access from a subclass or from the descriptor itself: hide it
        raise AttributeError("<class-local>")


def is_typeerror_due_to_wrong_call(exc: Exception, func: AnyFun) -> bool:
    """
    Determine if a TypeError is due to a wrong call to the function (incorrect arguments)
    by inspecting the exception message.
    """
    if not isinstance(exc, TypeError):
        return False
    func_name = func.__name__
    message = str(exc)
    return message.__contains__(f"{func_name}()")


removeprefix = getattr(
    str, "removeprefix", lambda s_, p_: s_[len(p_) :] if s_.startswith(p_) else s_
)


def read_dialect_and_sql(
    file_obj: IO[str],
    fallback_dialect: Optional[TSqlGlotDialect] = None,
) -> Tuple[TSqlGlotDialect, str]:
    """
    Read the first line of a file for the dialect (after the first colon),
    falls back to `fallback_dialect` if not found or empty,
    and then reads the rest as the SQL statement.

    Args:
        file_obj (IO[str]): A file-like object opened in text mode.
        fallback_dialect (Optional[str]): A fallback dialect to use if the first line
            does not specify a dialect.

    Returns:
        Tuple[str, str]: A tuple containing:
            - The extracted or fallback dialect as a string.
            - The SQL statement read from the rest of the file.
    """
    first_line = file_obj.readline()
    # e.g. something like: "dialect: clickhouse\n"
    parts = first_line.split(":", 1)
    parsed_dialect = cast(TSqlGlotDialect, parts[1].strip() if len(parts) > 1 else "")

    dialect = parsed_dialect if parsed_dialect else fallback_dialect

    sql_statement = file_obj.read()
    return dialect, sql_statement
