"""Utility functions for the mlfastflow package."""

import logging
import os
import re
import time
import warnings
from functools import singledispatch, wraps
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union

try:
    import polars as pl
except ImportError:  # pragma: no cover
    pl = None  # type: ignore[assignment]

try:
    import pandas as pd
except ImportError:  # pragma: no cover
    pd = None  # type: ignore[assignment]

# Module-level logger — all helpers share this.
logger = logging.getLogger(__name__)

__all__ = [
    "configure_logging",
    "timer_decorator",
    "concat_files",
    "profile",
    "csv2parquet",
    "parquet2csv",
    "get_info",
]

# ---------------------------------------------------------------------------
# Logging helper
# ---------------------------------------------------------------------------


def configure_logging(
    level: Union[int, str] = logging.INFO,
    fmt: str = "%(asctime)s  %(levelname)-8s  %(name)s — %(message)s",
    datefmt: str = "%Y-%m-%d %H:%M:%S",
) -> None:
    """Configure the root logger with a sensible default format.

    Call this once at the top of a script or notebook to get readable
    log output from all ``mlfastflow`` utilities.

    Args:
        level: Logging level (e.g. ``logging.DEBUG``, ``'INFO'``).
        fmt: ``logging.Formatter`` format string.
        datefmt: Date/time format string for the formatter.

    Example::

        from mlfastflow.utils import configure_logging
        configure_logging()          # INFO and above
        configure_logging("DEBUG")   # verbose
    """
    logging.basicConfig(level=level, format=fmt, datefmt=datefmt, force=True)


# ---------------------------------------------------------------------------
# timer_decorator
# ---------------------------------------------------------------------------


def timer_decorator(func):  # type: ignore[return]
    """Decorator that logs the execution time of the decorated function.

    Time is displayed in a human-friendly format: milliseconds for fast
    calls, seconds for moderate ones, and minutes for long-running ones.
    The decorator logs timing even when the wrapped function raises an
    exception (using a ``try/finally`` block).

    Args:
        func: The function to be decorated.

    Returns:
        The wrapped function with identical signature and return value.

    Example::

        @timer_decorator
        def train_model(data):
            ...
    """

    @wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start_time
            if elapsed < 1:
                time_str = f"{elapsed * 1000:.1f} ms"
            elif elapsed < 60:
                time_str = f"{elapsed:.2f} s"
            else:
                minutes, secs = divmod(elapsed, 60)
                time_str = f"{int(minutes)}m {secs:.1f}s"
            logger.info("Finished %r in %s", func.__name__, time_str)

    return wrapper_timer


# ---------------------------------------------------------------------------
# Helper: safe filename slug
# ---------------------------------------------------------------------------


def _safe_slug(text: str, fallback: str = "output") -> str:
    """Return a filesystem-safe slug derived from *text*.

    Strips characters that are problematic in filenames and replaces
    whitespace runs with underscores.  If the result is empty, *fallback*
    is returned instead.

    Args:
        text: Arbitrary string to slugify.
        fallback: Value returned when *text* yields an empty slug.

    Returns:
        A non-empty, filesystem-safe string.
    """
    slug = re.sub(r"[^\w\s-]", "", text).strip()
    slug = re.sub(r"[\s]+", "_", slug)
    return slug if slug else fallback


# ---------------------------------------------------------------------------
# concat_files
# ---------------------------------------------------------------------------


def concat_files(
    folder_path: Union[str, Path],
    file_type: str = "csv",
    add_source_column: bool = False,
    output_file: Optional[Union[str, Path]] = None,
    how: str = "diagonal_relaxed",
    overwrite: bool = False,
) -> str:
    """Concatenate all files of a specific type in a folder and its subfolders.

    Uses Polars lazy scanning where possible so that files are streamed
    through memory rather than loaded all at once.

    Args:
        folder_path: Path to the folder containing files to concatenate.
        file_type: File extension to look for (``'csv'`` or ``'parquet'``).
        add_source_column: If ``True``, adds a ``SOURCE`` column with the
            source filename.
        output_file: Path for the combined output file.  If ``None``, saves
            as ``<folder_name>_combined.<file_type>`` next to *folder_path*.
        how: Strategy for handling schema mismatches between files.
            ``'diagonal_relaxed'`` (default) fills missing columns with
            nulls and casts types.  ``'vertical'`` requires identical schemas.
        overwrite: If ``False`` (default), raise ``FileExistsError`` when the
            output file already exists.  Pass ``True`` to replace it.

    Returns:
        Absolute path to the created output file.

    Raises:
        ImportError: If Polars is not installed.
        FileNotFoundError: If *folder_path* does not exist or no matching
            files are found.
        FileExistsError: If the output file already exists and *overwrite*
            is ``False``.
        ValueError: If *folder_path* is not a directory or *file_type* is
            unsupported.
        RuntimeError: If every discovered file fails to read.
    """
    if pl is None:
        raise ImportError(
            "polars is required for concat_files. Install with: pip install polars"
        )

    folder_path = Path(folder_path).resolve()

    if not folder_path.exists():
        raise FileNotFoundError(f"Folder path does not exist: {folder_path}")
    if not folder_path.is_dir():
        raise ValueError(f"Path is not a directory: {folder_path}")

    file_type = file_type.lower().lstrip(".")
    supported_types = {"csv", "parquet"}
    if file_type not in supported_types:
        raise ValueError(
            f"Unsupported file type: '{file_type}'. Supported: {sorted(supported_types)}"
        )

    # Resolve output path
    if output_file is not None:
        out_path = Path(output_file).resolve()
    else:
        out_path = folder_path.parent / f"{folder_path.name}_combined.{file_type}"

    if out_path.exists() and not overwrite:
        raise FileExistsError(
            f"Output file already exists: {out_path}. Pass overwrite=True to replace it."
        )

    # Discover files
    all_files = sorted(folder_path.rglob(f"*.{file_type}"))

    if not all_files:
        raise FileNotFoundError(f"No .{file_type} files found in {folder_path}")

    logger.info("Found %d .%s file(s) to combine.", len(all_files), file_type)

    # Read each file into a lazy frame; accumulate failures separately
    frames: List[Any] = []
    failed: List[str] = []

    for fpath in all_files:
        try:
            if file_type == "csv":
                lf = pl.scan_csv(fpath)
            else:
                lf = pl.scan_parquet(fpath)

            if add_source_column:
                lf = lf.with_columns(pl.lit(fpath.name).alias("SOURCE"))

            frames.append(lf)
        except Exception as exc:
            logger.error("Failed to read %s: %s", fpath, exc)
            failed.append(str(fpath))

    if not frames:
        raise RuntimeError(
            f"All {len(all_files)} file(s) failed to read. Check logs for details."
        )

    if failed:
        logger.warning(
            "%d of %d file(s) failed to read and were skipped.",
            len(failed),
            len(all_files),
        )

    # Concatenate
    combined_lf = pl.concat(frames, how=how)

    out_path.parent.mkdir(parents=True, exist_ok=True)

    # Atomic-ish write: stream to a temp file first, then rename
    tmp_path = out_path.with_suffix(out_path.suffix + ".tmp")
    try:
        if file_type == "csv":
            combined_lf.collect().write_csv(tmp_path)
        else:
            # Prefer streaming sink when available (Polars ≥ 0.19)
            if hasattr(combined_lf, "sink_parquet"):
                combined_lf.sink_parquet(tmp_path)
            else:
                combined_lf.collect().write_parquet(tmp_path)

        tmp_path.replace(out_path)
    except Exception:
        tmp_path.unlink(missing_ok=True)
        raise

    size_mb = out_path.stat().st_size / (1024 * 1024)
    logger.info(
        "Combined %d file(s) → %s (%.2f MB).",
        len(frames),
        out_path,
        size_mb,
    )
    return str(out_path)


# ---------------------------------------------------------------------------
# profile
# ---------------------------------------------------------------------------


def profile(
    df: Any,
    title: str = "Data Profiling Report",
    output_path: Optional[Union[str, Path]] = None,
    minimal: bool = True,
    timestamp: bool = False,
) -> Any:
    """Generate a data profiling report for a DataFrame.

    Creates an HTML report using ``ydata-profiling`` and saves it to disk.
    Accepts both Pandas and Polars DataFrames (Polars is converted to
    Pandas internally, since ydata-profiling requires Pandas).

    Args:
        df: A Pandas or Polars DataFrame (or Polars LazyFrame).
        title: Title of the report (also used to derive the filename).
        output_path: Directory where the HTML report will be saved.
            If ``None``, saves to the current working directory.
        minimal: If ``True`` (default), generate a faster, lighter report.
            Set to ``False`` for a comprehensive report with correlations.
        timestamp: If ``True``, appends a ``YYYYMMDD_HHMMSS`` timestamp to
            the filename so successive calls never overwrite each other.

    Returns:
        The ``ProfileReport`` object, which can be used for further
        inspection or exported to other formats (e.g. ``report.to_json()``).

    Raises:
        ImportError: If ``ydata-profiling`` or ``pandas`` is not installed.
        TypeError: If *df* is not a recognised DataFrame type.
        ValueError: If the DataFrame is empty.
    """
    if pd is None:
        raise ImportError(
            "pandas is required for profile. Install with: pip install pandas"
        )

    # Validate type and convert to Pandas *before* importing the optional dep,
    # so a bad input gives a clean error message without a slow import.
    if isinstance(df, pd.DataFrame):
        pandas_df = df
    elif pl is not None and isinstance(df, pl.LazyFrame):
        pandas_df = df.collect().to_pandas()
    elif pl is not None and isinstance(df, pl.DataFrame):
        pandas_df = df.to_pandas()
    else:
        raise TypeError(
            f"Expected a Pandas or Polars DataFrame (or Polars LazyFrame), "
            f"got {type(df).__name__!r}"
        )

    if pandas_df.empty:
        raise ValueError("Cannot profile an empty DataFrame.")

    try:
        from ydata_profiling import ProfileReport
    except ImportError:
        raise ImportError(
            "ydata-profiling is required for profile. "
            "Install with: pip install ydata-profiling"
        )

    # Derive a safe filename from the title
    safe_title = _safe_slug(title, fallback="profiling_report")
    if timestamp:
        ts = time.strftime("%Y%m%d_%H%M%S")
        filename = f"{safe_title}_{ts}.html"
    else:
        filename = f"{safe_title}.html"

    if output_path is not None:
        output_dir = Path(output_path)
        output_dir.mkdir(parents=True, exist_ok=True)
        full_path = output_dir / filename
    else:
        full_path = Path(filename)

    # Suppress irrelevant widget warnings only during report generation.
    # Also silence the promotional banner via the environment variable,
    # restoring the original value afterwards.
    prev_banner = os.environ.get("YDATA_PROFILING_DISABLE_PREMIUM_BANNER")
    os.environ["YDATA_PROFILING_DISABLE_PREMIUM_BANNER"] = "1"

    try:
        with warnings.catch_warnings():
            warnings.filterwarnings("ignore", message=".*IProgress not found.*")
            report = ProfileReport(pandas_df, title=title, minimal=minimal)
            report.to_file(full_path)
    finally:
        if prev_banner is None:
            os.environ.pop("YDATA_PROFILING_DISABLE_PREMIUM_BANNER", None)
        else:
            os.environ["YDATA_PROFILING_DISABLE_PREMIUM_BANNER"] = prev_banner

    logger.info("Profile report saved to %s", full_path.resolve())
    return report


# ---------------------------------------------------------------------------
# csv2parquet
# ---------------------------------------------------------------------------


def csv2parquet(
    input_path: Union[str, Path],
    output_dir: Optional[Union[str, Path]] = None,
    sub_folders: bool = False,
    schema_overrides: Optional[Dict[str, Any]] = None,
    compression: str = "snappy",
    overwrite: bool = False,
) -> List[str]:
    """Convert CSV file(s) to Parquet format using Polars LazyFrame.

    Uses ``sink_parquet`` for streaming conversion when possible, which
    processes data in batches without loading the entire file into memory.
    Falls back to ``collect().write_parquet()`` for older Polars versions.

    Failed conversions are logged and skipped; the rest still succeed.

    Args:
        input_path: Path to a CSV file or directory containing CSV files.
        output_dir: Directory to save the Parquet files.  If ``None``,
            saves alongside the original CSV files.
        sub_folders: If ``True`` and *input_path* is a directory, recurse
            into subdirectories.
        schema_overrides: Column name → Polars dtype overrides for CSV
            parsing (e.g. ``{"id": pl.Utf8}``).
        compression: Parquet compression algorithm.  Options:
            ``'snappy'``, ``'zstd'``, ``'lz4'``, ``'gzip'``,
            ``'uncompressed'``.
        overwrite: If ``False`` (default), skip files that already have a
            corresponding ``.parquet`` file.  Pass ``True`` to re-convert.

    Returns:
        List of absolute paths to the created or overwritten Parquet files.

    Raises:
        ImportError: If Polars is not installed.
        FileNotFoundError: If *input_path* does not exist.
        ValueError: If *input_path* is neither a file nor a directory.
    """
    if pl is None:
        raise ImportError(
            "polars is required for csv2parquet. Install with: pip install polars"
        )

    input_path = Path(input_path).resolve()

    if not input_path.exists():
        raise FileNotFoundError(f"Input path does not exist: {input_path}")

    # Resolve output directory
    if output_dir:
        output_base: Optional[Path] = Path(output_dir).resolve()
        output_base.mkdir(parents=True, exist_ok=True)
    else:
        output_base = None

    # Collect CSV files to process
    csv_files: List[Path]
    base_dir: Path

    if input_path.is_file():
        if input_path.suffix.lower() != ".csv":
            logger.warning("Not a CSV file, skipping: %s", input_path)
            return []
        csv_files = [input_path]
        base_dir = input_path.parent
    elif input_path.is_dir():
        pattern = "*.csv"
        csv_files = sorted(
            input_path.rglob(pattern) if sub_folders else input_path.glob(pattern)
        )
        base_dir = input_path
        if not csv_files:
            logger.info("No CSV files found in %s", input_path)
            return []
        logger.info("Found %d CSV file(s) to convert.", len(csv_files))
    else:
        raise ValueError(
            f"Input path is neither a file nor a directory: {input_path}"
        )

    created_files: List[str] = []
    skipped = 0
    failed: List[str] = []

    for csv_file in csv_files:
        # Determine output path
        if output_base is not None:
            rel = csv_file.relative_to(base_dir)
            out_file = output_base / rel.with_suffix(".parquet")
            out_file.parent.mkdir(parents=True, exist_ok=True)
        else:
            out_file = csv_file.with_suffix(".parquet")

        # Skip if already exists and overwrite is off
        if out_file.exists() and not overwrite:
            logger.info("Skipping (already exists): %s", out_file)
            skipped += 1
            continue

        # Atomic-ish write via a temp file to avoid corrupt output on failure
        tmp_out = out_file.with_suffix(".parquet.tmp")
        try:
            lf = pl.scan_csv(csv_file, schema_overrides=schema_overrides)

            if hasattr(lf, "sink_parquet"):
                lf.sink_parquet(tmp_out, compression=compression)
            else:
                lf.collect().write_parquet(tmp_out, compression=compression)

            tmp_out.replace(out_file)

            size_mb = out_file.stat().st_size / (1024 * 1024)
            logger.info(
                "Converted %s → %s (%.2f MB)", csv_file.name, out_file, size_mb
            )
            created_files.append(str(out_file))

        except Exception as exc:
            tmp_out.unlink(missing_ok=True)
            logger.error("Failed to convert %s: %s", csv_file, exc)
            failed.append(str(csv_file))

    # Summary
    parts: List[str] = []
    if created_files:
        parts.append(f"{len(created_files)} converted")
    if skipped:
        parts.append(f"{skipped} skipped")
    if failed:
        parts.append(f"{len(failed)} failed")
    logger.info("csv2parquet summary: %s.", ", ".join(parts) if parts else "no files processed")

    return created_files


# ---------------------------------------------------------------------------
# parquet2csv  (complement to csv2parquet)
# ---------------------------------------------------------------------------


def parquet2csv(
    input_path: Union[str, Path],
    output_dir: Optional[Union[str, Path]] = None,
    sub_folders: bool = False,
    separator: str = ",",
    overwrite: bool = False,
) -> List[str]:
    """Convert Parquet file(s) to CSV format using Polars.

    The natural complement to :func:`csv2parquet`.  Reads each Parquet
    file as a LazyFrame and writes it as a CSV, preserving directory
    structure when *output_dir* is set.

    Args:
        input_path: Path to a Parquet file or directory containing Parquet
            files.
        output_dir: Directory to save the CSV files.  If ``None``, saves
            alongside the original Parquet files.
        sub_folders: If ``True`` and *input_path* is a directory, recurse
            into subdirectories.
        separator: Field delimiter for the output CSV (default ``','``).
        overwrite: If ``False`` (default), skip files that already have a
            corresponding ``.csv`` file.  Pass ``True`` to re-convert.

    Returns:
        List of absolute paths to the created or overwritten CSV files.

    Raises:
        ImportError: If Polars is not installed.
        FileNotFoundError: If *input_path* does not exist.
        ValueError: If *input_path* is neither a file nor a directory.
    """
    if pl is None:
        raise ImportError(
            "polars is required for parquet2csv. Install with: pip install polars"
        )

    input_path = Path(input_path).resolve()

    if not input_path.exists():
        raise FileNotFoundError(f"Input path does not exist: {input_path}")

    if output_dir:
        output_base: Optional[Path] = Path(output_dir).resolve()
        output_base.mkdir(parents=True, exist_ok=True)
    else:
        output_base = None

    parquet_files: List[Path]
    base_dir: Path

    if input_path.is_file():
        if input_path.suffix.lower() != ".parquet":
            logger.warning("Not a Parquet file, skipping: %s", input_path)
            return []
        parquet_files = [input_path]
        base_dir = input_path.parent
    elif input_path.is_dir():
        pattern = "*.parquet"
        parquet_files = sorted(
            input_path.rglob(pattern) if sub_folders else input_path.glob(pattern)
        )
        base_dir = input_path
        if not parquet_files:
            logger.info("No Parquet files found in %s", input_path)
            return []
        logger.info("Found %d Parquet file(s) to convert.", len(parquet_files))
    else:
        raise ValueError(
            f"Input path is neither a file nor a directory: {input_path}"
        )

    created_files: List[str] = []
    skipped = 0
    failed: List[str] = []

    for pq_file in parquet_files:
        if output_base is not None:
            rel = pq_file.relative_to(base_dir)
            out_file = output_base / rel.with_suffix(".csv")
            out_file.parent.mkdir(parents=True, exist_ok=True)
        else:
            out_file = pq_file.with_suffix(".csv")

        if out_file.exists() and not overwrite:
            logger.info("Skipping (already exists): %s", out_file)
            skipped += 1
            continue

        tmp_out = out_file.with_suffix(".csv.tmp")
        try:
            df = pl.read_parquet(pq_file)
            df.write_csv(tmp_out, separator=separator)
            tmp_out.replace(out_file)

            size_mb = out_file.stat().st_size / (1024 * 1024)
            logger.info(
                "Converted %s → %s (%.2f MB)", pq_file.name, out_file, size_mb
            )
            created_files.append(str(out_file))

        except Exception as exc:
            tmp_out.unlink(missing_ok=True)
            logger.error("Failed to convert %s: %s", pq_file, exc)
            failed.append(str(pq_file))

    parts: List[str] = []
    if created_files:
        parts.append(f"{len(created_files)} converted")
    if skipped:
        parts.append(f"{skipped} skipped")
    if failed:
        parts.append(f"{len(failed)} failed")
    logger.info("parquet2csv summary: %s.", ", ".join(parts) if parts else "no files processed")

    return created_files



# ---------------------------------------------------------------------------
# get_info  — singledispatch, extensible per type
# ---------------------------------------------------------------------------


@singledispatch
def get_info(obj: Any) -> Dict[str, Any]:
    """Return a concise summary dict describing *obj*.

    Dispatches on the type of *obj* so that new object types can be
    supported in the future simply by registering a new handler — no
    changes to this function required::

        @get_info.register
        def _(client: BigQueryClient) -> Dict[str, Any]:
            ...

    Currently supported types:

    * ``pandas.DataFrame``
    * ``polars.DataFrame``
    * ``polars.LazyFrame``

    Args:
        obj: The object to inspect.

    Returns:
        A dict whose keys depend on the type of *obj*.  For DataFrame
        types the keys are:

        * ``rows`` – row count (``None`` for LazyFrames)
        * ``columns`` – column count
        * ``column_names`` – list of column names
        * ``dtypes`` – ``{col: dtype_str}`` mapping
        * ``null_counts`` – ``{col: null_count}`` (``None`` for LazyFrames)
        * ``memory_mb`` – approximate in-memory size (``None`` for LazyFrames)
        * ``backend`` – ``'pandas'``, ``'polars'``, or ``'polars-lazy'``

    Raises:
        TypeError: If *obj*'s type has no registered handler.
    """
    raise TypeError(
        f"get_info: no handler registered for type {type(obj).__name__!r}. "
        f"Register one with @get_info.register."
    )


# --- Pandas handler ---
if pd is not None:
    @get_info.register(pd.DataFrame)
    def _get_info_pandas(df: Any) -> Dict[str, Any]:
        return {
            "rows": len(df),
            "columns": len(df.columns),
            "column_names": list(df.columns),
            "dtypes": {c: str(t) for c, t in df.dtypes.items()},
            "null_counts": df.isnull().sum().to_dict(),
            "memory_mb": round(
                df.memory_usage(deep=True).sum() / (1024 * 1024), 4
            ),
            "backend": "pandas",
        }


# --- Polars handlers ---
if pl is not None:
    @get_info.register(pl.LazyFrame)
    def _get_info_polars_lazy(lf: Any) -> Dict[str, Any]:
        # collect_schema() (Polars ≥ 0.20) avoids a PerformanceWarning;
        # fall back to .schema for older versions.
        schema = (
            lf.collect_schema()
            if hasattr(lf, "collect_schema")
            else lf.schema
        )
        col_names = (
            list(schema.names()) if hasattr(schema, "names") else list(schema.keys())
        )
        dtypes = (
            {c: str(schema[c]) for c in schema.names()}
            if hasattr(schema, "names")
            else {c: str(t) for c, t in schema.items()}
        )
        return {
            "rows": None,
            "columns": len(schema),
            "column_names": col_names,
            "dtypes": dtypes,
            "null_counts": None,
            "memory_mb": None,
            "backend": "polars-lazy",
        }

    @get_info.register(pl.DataFrame)
    def _get_info_polars(df: Any) -> Dict[str, Any]:
        return {
            "rows": df.height,
            "columns": df.width,
            "column_names": df.columns,
            "dtypes": {c: str(t) for c, t in zip(df.columns, df.dtypes)},
            "null_counts": {c: df[c].null_count() for c in df.columns},
            "memory_mb": round(
                sum(df[c].estimated_size() for c in df.columns) / (1024 * 1024), 4
            ),
            "backend": "polars",
        }


# --- BigQueryClient handler ---
# Deferred import to avoid a circular dependency:
#   bigqueryclient.py  →  utils.py (timer_decorator)
#   utils.py           →  bigqueryclient.py  ← would be circular at module load
# Registering inside a try/except means get_info still works when
# google-cloud-bigquery is not installed.
try:
    from mlfastflow.bigqueryclient import BigQueryClient as _BigQueryClient

    @get_info.register(_BigQueryClient)
    def _get_info_bq(client: Any) -> Dict[str, Any]:
        """Return a summary dict for a ``BigQueryClient`` instance.

        Returns:
            A dict with keys:

            * ``project_id`` – GCP project
            * ``dataset_id`` – BigQuery dataset
            * ``bq_client`` – ``'initialized'`` or ``'closed'``
            * ``storage_client`` – ``'initialized'`` or ``'not initialized'``
            * ``credentials`` – ``'set'`` or ``'not set'``
            * ``query_count`` – number of queries run since last connection refresh
            * ``tables`` – list of table names in the dataset (empty list if
              the client is closed or the call fails)
        """
        bq_ok = client.client is not None
        tables: List[str] = []
        if bq_ok:
            try:
                tables = client.list_tables()
            except Exception as exc:  # network error, permission denied, etc.
                logger.warning("get_info: could not list tables: %s", exc)

        return {
            "project_id": client.project_id,
            "dataset_id": client.dataset_id,
            "bq_client": "initialized" if bq_ok else "closed",
            "storage_client": (
                "initialized" if client._storage_client is not None
                else "not initialized"
            ),
            "credentials": "set" if client.credentials is not None else "not set",
            "query_count": client._query_count,
            "tables": tables,
        }

except ImportError:
    pass  # google-cloud-bigquery not installed; handler simply won't be registered
