"""
Importer for Asylum Research / Ergo HDF5 files (.h5, .hdf5, .he5).

Asylum Research instruments store scan metadata in a sidecar group rather
than as dataset attributes.  This importer reads physical dimensions from:

  Image/DataSetInfo/Global/Channels/<channel>/ImageDims
    DimScaling   – (2,2) array: [[Y_start, Y_end], [X_start, X_end]]
                   absolute physical coordinate ranges in DimUnits
    DimExtents   – pixel counts [yres, xres] (stored in a child group, not used for sizing)
    DimUnits     – lateral unit strings [Y_unit, X_unit]
    DataUnits    – Z unit string

If the sidecar group is absent (generic HDF5), standard dataset attributes
are used as a fallback:

  xreal / yreal   – physical scan size in metres  (fallback: 1e-6)
  xoff  / yoff    – position offset in metres      (fallback: 0)
  si_unit_xy      – lateral unit string            (fallback: "m")
  si_unit_z       – value unit string              (fallback: "m")

Requires:
    pip install h5py
"""

from __future__ import annotations

from pathlib import Path

import numpy as np

from backend.data_types import DataField


extensions = frozenset({".h5", ".hdf5", ".he5"})
calibrated = True   # we attempt to read physical metadata


def _iter_2d_datasets(h5file):
    """Yield (name, dataset) for every 2-D numeric dataset in the file."""
    import h5py

    def _visit(name, obj):
        if isinstance(obj, h5py.Dataset) and obj.ndim == 2 and np.issubdtype(obj.dtype, np.number):
            results.append((name, obj))

    results: list = []
    h5file.visititems(_visit)
    return results


def _attr_str(attrs, key: str, default: str) -> str:
    val = attrs.get(key)
    if val is None:
        return default
    if isinstance(val, bytes):
        return val.decode("utf-8", errors="replace").strip() or default
    return str(val).strip() or default


def _attr_float(attrs, key: str, default: float) -> float:
    val = attrs.get(key)
    if val is None:
        return default
    try:
        return float(val)
    except (TypeError, ValueError):
        return default


def _ar_image_dims(f, ds_name: str) -> dict | None:
    """
    Look up Asylum Research ImageDims metadata for a dataset.

    AR .h5 files store scan dimensions in a sibling group rather than as
    dataset attributes.  Given a dataset path like:
      "Image/DataSet/Resolution 0/Frame 0/Adhesion:Retrace/Image"
    the channel name is the second-to-last component ("Adhesion:Retrace"),
    and the metadata lives at:
      "Image/DataSetInfo/Global/Channels/<channel>/ImageDims"

    DimScaling is a (2, 2) array of *absolute physical coordinate ranges*
    (not per-pixel step sizes), stored Y-first:
      scaling[0, :] = [Y_start, Y_end]
      scaling[1, :] = [X_start, X_end]
    Both values are in the unit given by DimUnits.

    Returns a dict with xreal, yreal, xoff, yoff, si_unit_xy, si_unit_z,
    or None if the group isn't found.
    """
    import h5py

    parts = ds_name.split("/")
    if len(parts) < 2:
        return None
    channel = parts[-2]

    dims_path = f"Image/DataSetInfo/Global/Channels/{channel}/ImageDims"
    grp = f.get(dims_path)
    if not isinstance(grp, h5py.Group):
        return None

    scaling = grp.attrs.get("DimScaling")   # shape (2, 2): [[Y_start, Y_end], [X_start, X_end]]
    dim_units = grp.attrs.get("DimUnits")   # array of unit strings, e.g. ['m', 'm'] (Y then X)
    data_units = grp.attrs.get("DataUnits") # Z unit string, e.g. 'N'

    if scaling is None or np.asarray(scaling).shape != (2, 2):
        return None

    scaling = np.asarray(scaling, dtype=np.float64)
    # Y axis first (row-major), then X — matching numpy's (rows, cols) convention.
    y_start, y_end = float(scaling[0, 0]), float(scaling[0, 1])
    x_start, x_end = float(scaling[1, 0]), float(scaling[1, 1])

    xreal = abs(x_end - x_start) or 1e-6
    yreal = abs(y_end - y_start) or 1e-6
    xoff  = min(x_start, x_end)
    yoff  = min(y_start, y_end)

    def _decode(raw, default="m") -> str:
        if raw is None:
            return default
        if hasattr(raw, "__iter__") and not isinstance(raw, (str, bytes)):
            raw = list(raw)[0] if len(raw) else default
        if isinstance(raw, bytes):
            return raw.decode("utf-8", errors="replace").strip() or default
        return str(raw).strip() or default

    # DimUnits is [Y_unit, X_unit]; X unit is the canonical lateral unit.
    if dim_units is not None and len(dim_units) >= 2:
        xy_unit = _decode(dim_units[1])
    elif dim_units is not None and len(dim_units) >= 1:
        xy_unit = _decode(dim_units[0])
    else:
        xy_unit = "m"

    return {
        "xreal": xreal,
        "yreal": yreal,
        "xoff":  xoff,
        "yoff":  yoff,
        "si_unit_xy": xy_unit,
        "si_unit_z":  _decode(data_units),
    }


def load(path: Path) -> list[DataField]:
    try:
        import h5py
    except ImportError:
        raise ImportError("Install 'h5py' to load HDF5 files:  pip install h5py")

    with h5py.File(str(path), "r") as f:
        datasets = _iter_2d_datasets(f)
        if not datasets:
            raise ValueError(f"No 2-D numeric datasets found in {path.name}")

        fields = []
        for name, ds in datasets:
            data = np.asarray(ds, dtype=np.float64)

            # Try Asylum Research sidecar metadata first, then dataset attrs.
            ar = _ar_image_dims(f, name)
            if ar:
                fields.append(DataField(
                    data=data,
                    xreal=ar["xreal"], yreal=ar["yreal"],
                    xoff=ar["xoff"],   yoff=ar["yoff"],
                    si_unit_xy=ar["si_unit_xy"],
                    si_unit_z=ar["si_unit_z"],
                ))
            else:
                attrs = ds.attrs
                fields.append(DataField(
                    data=data,
                    xreal=_attr_float(attrs, "xreal", 1e-6),
                    yreal=_attr_float(attrs, "yreal", 1e-6),
                    xoff=_attr_float(attrs, "xoff", 0.0),
                    yoff=_attr_float(attrs, "yoff", 0.0),
                    si_unit_xy=_attr_str(attrs, "si_unit_xy", "m"),
                    si_unit_z=_attr_str(attrs, "si_unit_z", "m"),
                ))
    return fields


def _display_names(full_names: list[str]) -> list[str]:
    """
    Derive short display names from HDF5 dataset paths.

    Rules (all comparisons case-insensitive):
      1. All thumbnail datasets are filtered out.
      2. Display name = second-to-last path component (drops the leaf like
         "/image" or "/thumbnail").
      3. "global" channels sort to the front.
      4. If two kept datasets share the same second-to-last name, the leaf is
         appended to disambiguate.

    Returns a list in sorted order (not parallel to full_names).
    """
    from collections import Counter

    # Filter out all thumbnail datasets.
    kept: list[tuple[int, str]] = []   # (original index, full name)
    for i, name in enumerate(full_names):
        if name.split("/")[-1].lower() == "thumbnail":
            continue
        kept.append((i, name))

    # Sort: "global" second-to-last first, then alphabetical.
    def _sort_key(item: tuple[int, str]) -> tuple[int, str]:
        parts = item[1].split("/")
        second_last = parts[-2].lower() if len(parts) >= 2 else parts[-1].lower()
        return (0 if second_last == "global" else 1, second_last)

    kept.sort(key=_sort_key)

    # Build short names (second-to-last), disambiguate clashes.
    short = [
        (parts[-2] if len(parts := name.split("/")) >= 2 else parts[-1])
        for _, name in kept
    ]
    counts = Counter(short)
    disambiguated = [
        f"{s}/{name.split('/')[-1]}" if counts[s] > 1 else s
        for s, (_, name) in zip(short, kept)
    ]

    return disambiguated


def channel_names(path: Path) -> list[str]:
    try:
        import h5py
    except ImportError:
        return []
    try:
        with h5py.File(str(path), "r") as f:
            datasets = _iter_2d_datasets(f)
            full_names = [name for name, _ in datasets]
        return [n for n in _display_names(full_names) if n is not None]
    except Exception:
        return []
