from __future__ import annotations

from collections.abc import Callable, Iterator
from contextlib import contextmanager
from os.path import basename
from pathlib import Path
from typing import Any, TypeVar

import h5py
import numpy as np
from h5py.version import version_tuple as h5py_version

from .models import (
    AttributeMetadata,
    H5pyEntity,
    LinkResolution,
    Selection,
    Stats,
    StrDtype,
    TypeMetadata,
)


class NotFoundError(Exception):
    pass


class PathError(NotFoundError):
    pass


class LinkError(NotFoundError):
    pass


class QueryArgumentError(ValueError):
    pass


def _get_attr_id(entity_attrs: h5py.AttributeManager, attr_name: str):
    return entity_attrs.get_id(attr_name)


def _legacy_get_attr_id(entity_attrs: h5py.AttributeManager, attr_name: str):
    return h5py.h5a.open(entity_attrs._id, entity_attrs._e(attr_name))


get_attr_id = (
    _legacy_get_attr_id
    if h5py_version.major <= 2 and h5py_version.minor <= 9
    else _get_attr_id
)


def attr_metadata(
    entity_attrs: h5py.AttributeManager, attr_name: str
) -> AttributeMetadata:
    attrId = get_attr_id(entity_attrs, attr_name)

    return {
        "name": attr_name,
        "shape": attrId.shape,
        "type": get_type_metadata(attrId.get_type()),
    }


def get_entity_from_file(
    h5file: h5py.File,
    path: str,
    resolve_links: LinkResolution = LinkResolution.ONLY_VALID,
) -> H5pyEntity:
    if path == "/":
        return h5file[path]

    link = h5file.get(path, getlink=True)

    if link is None:
        raise PathError(f"{path} is not a valid path in {basename(h5file.filename)}")

    if isinstance(link, h5py.ExternalLink) or isinstance(link, h5py.SoftLink):
        if resolve_links == LinkResolution.NONE:
            return link

        try:
            return h5file[path]
        except (OSError, KeyError):
            if resolve_links == LinkResolution.ONLY_VALID:
                return link

            raise LinkError(
                f"Cannot resolve {link} at {path} of {basename(h5file.filename)}"
            )

    return h5file[path]


def parse_slice(slice_str: str) -> tuple[slice | int, ...]:
    """
    Parses a string containing a slice under NumPy format.

    Examples:
        '5' => (5,)
        '1, 2:5' => (1, slice(2,5))
        '0:10:5, 2, 3:' => (slice(0, 10, 5), 2, slice(3, None, None))

    :param slice_str: String containing the slice
    """
    if "," not in slice_str:
        return (parse_slice_member(slice_str),)

    slice_members = slice_str.split(",")

    return tuple(parse_slice_member(s) for s in slice_members)


def parse_slice_member(slice_member: str) -> slice | int:
    if ":" not in slice_member:
        return int(slice_member)

    slice_params = slice_member.split(":")
    if len(slice_params) == 2:
        start, stop = slice_params

        return slice(
            int(start) if start != "" else 0, int(stop) if stop != "" else None
        )

    if len(slice_params) == 3:
        start, stop, step = slice_params

        return slice(
            int(start) if start != "" else None,
            int(stop) if stop != "" else None,
            int(step) if step != "" else None,
        )

    raise TypeError(f"{slice_member} is not a valid slice")


def sorted_dict(*args: tuple[str, Any]):
    return dict(sorted(args, key=lambda entry: entry[0]))


def get_type_metadata(type_id: h5py.h5t.TypeID) -> TypeMetadata:
    base_metadata: TypeMetadata = {
        "class": type_id.get_class(),
        "dtype": stringify_dtype(type_id.dtype),
        "size": type_id.get_size(),
    }
    members = {}

    if isinstance(type_id, h5py.h5t.TypeIntegerID):
        return {
            **base_metadata,  # type: ignore
            "order": type_id.get_order(),
            "sign": type_id.get_sign(),
        }

    if isinstance(type_id, h5py.h5t.TypeFloatID):
        return {
            **base_metadata,  # type: ignore
            "order": type_id.get_order(),
        }

    if isinstance(type_id, h5py.h5t.TypeStringID):
        return {
            **base_metadata,  # type: ignore
            "cset": type_id.get_cset(),
            "strpad": type_id.get_strpad(),
            "vlen": type_id.is_variable_str(),
        }

    if isinstance(type_id, h5py.h5t.TypeBitfieldID):
        return {**base_metadata, "order": type_id.get_order()}  # type: ignore

    if isinstance(type_id, h5py.h5t.TypeOpaqueID):
        return {**base_metadata, "tag": type_id.get_tag()}  # type: ignore

    if isinstance(type_id, h5py.h5t.TypeCompoundID):
        for i in range(0, type_id.get_nmembers()):
            members[type_id.get_member_name(i).decode("utf-8")] = get_type_metadata(
                type_id.get_member_type(i)
            )

        return {**base_metadata, "members": members}  # type: ignore

    if isinstance(type_id, h5py.h5t.TypeEnumID):
        for i in range(0, type_id.get_nmembers()):
            members[type_id.get_member_name(i).decode("utf-8")] = (
                type_id.get_member_value(i)
            )

        return {
            **base_metadata,  # type: ignore
            "members": members,
            "base": get_type_metadata(type_id.get_super()),
        }

    if isinstance(type_id, h5py.h5t.TypeVlenID):
        return {**base_metadata, "base": get_type_metadata(type_id.get_super())}  # type: ignore

    if isinstance(type_id, h5py.h5t.TypeArrayID):
        return {
            **base_metadata,  # type: ignore
            "dims": type_id.get_array_dims(),
            "base": get_type_metadata(type_id.get_super()),
        }

    return base_metadata


def _sanitize_dtype(dtype: np.dtype) -> np.dtype:
    """Sanitize numpy dtype to one with a matching typed array in modern JavaScript."""

    # Convert float16 to float32
    if dtype.kind == "f" and dtype.itemsize < 4:
        return np.dtype("<f4")

    # Convert float128 to float64 (unavoidable loss of precision)
    if dtype.kind == "f" and dtype.itemsize > 8:
        return np.dtype("<f8")

    # Convert native and big-endian byte-orders to little-endian
    if dtype.byteorder == "=" or dtype.byteorder == ">":
        return dtype.newbyteorder("<")

    return dtype


T = TypeVar("T", np.ndarray, np.number, np.bool_)


def convert(data: T, dtype: str | None = "origin") -> T:
    """Convert array or numpy scalar to given dtype query param

    :param data: nD array or scalar to convert
    :param dtype: Data type conversion query parameter
        - `origin` (default): No conversion
        - `safe`: Convert to type with matching JS TypedArray (https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Global_Objects/TypedArray)

    :raises QueryArgumentError: When using `dtype=safe` to convert non-numeric data
    :raises QueryArgumentError: For unsupported `dtype` argument
    """
    if dtype in ("origin", None):
        return data

    if dtype == "safe":
        if not is_numeric_data(data):
            raise QueryArgumentError(f"Unsupported dtype {dtype} for non-numeric data")
        return data.astype(_sanitize_dtype(data.dtype), order="C", copy=False)

    raise QueryArgumentError(f"Unsupported dtype {dtype}")


def is_numeric_data(data: np.ndarray | np.number | np.bool_ | bytes) -> bool:
    if not isinstance(data, (np.ndarray, np.number, np.bool_)):
        return False

    return np.issubdtype(data.dtype, np.number) or np.issubdtype(data.dtype, np.bool_)


def get_array_stats(data: np.ndarray) -> Stats:
    if data.size == 0:
        return {
            "strict_positive_min": None,
            "positive_min": None,
            "min": None,
            "max": None,
            "mean": None,
            "std": None,
        }

    cast = float if np.issubdtype(data.dtype, np.floating) else int
    strict_positive_data = data[data > 0]
    positive_data = data[data >= 0]
    return {
        "strict_positive_min": (
            cast(np.min(strict_positive_data))
            if strict_positive_data.size != 0
            else None
        ),
        "positive_min": (
            cast(np.min(positive_data)) if positive_data.size != 0 else None
        ),
        "min": cast(np.min(data)),
        "max": cast(np.max(data)),
        "mean": cast(np.mean(data)),
        "std": cast(np.std(data)),
    }


def hdf_path_join(prefix: str | None, suffix: str):
    if prefix is None or prefix == "/":
        return f"/{suffix}"

    return f"{prefix.rstrip('/')}/{suffix}"


def parse_bool_arg(query_arg: str | None, fallback: bool) -> bool:
    if query_arg is None:
        return fallback

    return query_arg.lower() != "false"


def parse_link_resolution_arg(
    raw_query_arg: str | None, fallback: LinkResolution
) -> LinkResolution:
    if raw_query_arg is None:
        return fallback

    query_arg = raw_query_arg.lower()

    # Checking for "true"/"false" to keep the same behaviour as when the arg was a boolean
    if query_arg in ("true", LinkResolution.ALL):
        return LinkResolution.ALL

    if query_arg in ("false", LinkResolution.NONE):
        return LinkResolution.NONE

    if query_arg == LinkResolution.ONLY_VALID:
        return LinkResolution.ONLY_VALID

    raise QueryArgumentError(
        f"{raw_query_arg} is not a valid value for link resolution. Accepted values are: {LinkResolution.ALL}, {LinkResolution.NONE} or {LinkResolution.ONLY_VALID}"
    )


def get_dataset_slice(dataset: h5py.Dataset, selection: Selection):
    if selection is None:
        return dataset[()]

    if isinstance(selection, str):
        parsed_slice = parse_slice(selection)
        if len(parsed_slice) > dataset.ndim:
            raise ValueError(
                f"{selection} has too many members to slice a {dataset.ndim}D dataset"
            )
        return dataset[parsed_slice]

    return dataset[selection]


def get_filters(
    dataset: h5py.Dataset,
) -> list[dict[str, int | str]] | None:
    property_list = dataset.id.get_create_plist()

    n_filters = property_list.get_nfilters()
    if n_filters <= 0:
        return None

    return [get_filter_info(property_list.get_filter(i)) for i in range(n_filters)]


def get_filter_info(
    filter: tuple[int, int, tuple[int, ...], str],
) -> dict[str, int | str]:
    # https://api.h5py.org/h5p.html#h5py.h5p.PropDCID.get_filter
    filter_id, _, _, name = filter

    return {"id": filter_id, "name": name}


def stringify_dtype(dtype: np.dtype) -> StrDtype:
    if dtype.fields is None:
        return dtype.str

    return {
        k: stringify_dtype(dtype_tuple[0]) for k, dtype_tuple in dtype.fields.items()
    }


@contextmanager
def open_file_with_error_fallback(
    filepath: str | Path,
    create_error: Callable[[int, str], Exception],
    h5py_options: dict[str, Any] = {},
) -> Iterator[h5py.File]:
    try:
        with h5py.File(filepath, "r", **h5py_options) as f:
            yield f
    except OSError as e:
        if isinstance(e, FileNotFoundError) or "No such file or directory" in str(e):
            raise create_error(404, "File not found!")
        if isinstance(e, PermissionError) or "Permission denied" in str(e):
            raise create_error(403, "Cannot read file: Permission denied!")
        raise e
