"""
Helpers for detecting and standardizing null-like string values.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from datetime import date, datetime
from typing import Any

import polars as pl

DEFAULT_NULL_SAMPLE_ROWS = 20000
DEFAULT_NULL_INFER_SAMPLE_ROWS = 2000
DEFAULT_NULL_INFER_MIN_VALUES = 10
DEFAULT_NULL_INFER_SUCCESS_RATIO = 0.95
DEFAULT_NULL_TOKENS: tuple[str, ...] = (
    '""',
    "N/A",
    "NA",
    "NULL",
    "None",
    "NaN",
    "-",
    "--",
    "?",
    "nil",
    "missing",
)

_EMPTY_TOKEN_MARKERS = {'""', "''"}
_BOOL_TRUE = {"true", "t", "yes", "y", "1"}
_BOOL_FALSE = {"false", "f", "no", "n", "0"}


def default_null_tokens(configured: Sequence[str] | None = None) -> tuple[str, ...]:
    if configured is None:
        return DEFAULT_NULL_TOKENS
    return tuple(configured)


def normalize_null_tokens(tokens: Sequence[str]) -> list[str]:
    normalized: list[str] = []
    seen: set[str] = set()
    for token in tokens:
        cleaned = str(token).strip()
        if not cleaned:
            continue
        normalized_token = normalize_null_token(cleaned)
        if normalized_token in seen:
            continue
        seen.add(normalized_token)
        normalized.append(normalized_token)
    return normalized


def normalize_null_token(token: str) -> str:
    cleaned = token.strip()
    if cleaned in _EMPTY_TOKEN_MARKERS:
        return ""
    return cleaned.casefold()


def parse_null_token_lines(text: str) -> list[str]:
    tokens: list[str] = []
    seen: set[str] = set()
    for line in text.splitlines():
        cleaned = line.strip()
        if not cleaned:
            continue
        normalized = normalize_null_token(cleaned)
        if normalized in seen:
            continue
        seen.add(normalized)
        tokens.append(normalized)
    return tokens


def parse_null_tokens_argument(args: Sequence[str]) -> list[str]:
    if not args:
        return []
    if len(args) == 1:
        return parse_null_token_lines(args[0])
    return parse_null_token_lines("\n".join(args))


def apply_null_tokens(value: object, tokens: set[str]) -> object | None:
    if value is None:
        return None
    if isinstance(value, bytes):
        text = value.decode("utf-8", errors="replace")
        normalized = text.strip().casefold()
        return None if normalized in tokens else value
    if isinstance(value, str):
        normalized = value.strip().casefold()
        return None if normalized in tokens else value
    return value


def infer_dtype_from_sample(
    values: Sequence[object],
    tokens: Sequence[str],
    *,
    min_values: int = DEFAULT_NULL_INFER_MIN_VALUES,
    success_ratio: float = DEFAULT_NULL_INFER_SUCCESS_RATIO,
) -> pl.DataType | None:
    token_set = {normalize_null_token(token) for token in tokens}
    total = 0
    parsed = 0
    bool_count = 0
    int_count = 0
    float_count = 0
    date_count = 0
    datetime_count = 0
    float_fractional = False
    seen_bool_word = False
    for raw in values:
        value = apply_null_tokens(raw, token_set)
        if value is None:
            continue
        total += 1
        if isinstance(value, bool):
            bool_count += 1
            parsed += 1
            seen_bool_word = True
            continue
        if isinstance(value, int) and not isinstance(value, bool):
            int_count += 1
            parsed += 1
            continue
        if isinstance(value, float):
            float_count += 1
            parsed += 1
            if not value.is_integer():
                float_fractional = True
            continue
        if isinstance(value, bytes):
            text = value.decode("utf-8", errors="replace")
        elif isinstance(value, str):
            text = value
        else:
            return None
        stripped = text.strip()
        if not stripped:
            return None
        lowered = stripped.casefold()
        if lowered in _BOOL_TRUE or lowered in _BOOL_FALSE:
            bool_count += 1
            parsed += 1
            if lowered not in {"0", "1"}:
                seen_bool_word = True
            continue
        try:
            int(stripped)
        except Exception:
            pass
        else:
            int_count += 1
            parsed += 1
            continue
        try:
            parsed_float = float(stripped)
        except Exception:
            parsed_float = None
        if parsed_float is not None:
            float_count += 1
            parsed += 1
            if not parsed_float.is_integer():
                float_fractional = True
            continue
        if _try_parse_datetime(stripped) is not None:
            datetime_count += 1
            parsed += 1
            continue
        if _try_parse_date(stripped) is not None:
            date_count += 1
            parsed += 1
            continue

    if total < min_values:
        return None
    if total > 0 and parsed / total < success_ratio:
        return None
    if datetime_count == total:
        return pl.Datetime
    if date_count == total:
        return pl.Date
    if date_count + datetime_count == total and datetime_count > 0:
        return pl.Datetime
    if bool_count == total and seen_bool_word:
        return pl.Boolean
    if int_count + float_count == total:
        if float_fractional:
            return pl.Float64
        return pl.Int64
    return None


def _try_parse_date(text: str) -> date | None:
    try:
        return date.fromisoformat(text)
    except Exception:
        return None


def _try_parse_datetime(text: str) -> datetime | None:
    candidate = text.replace("Z", "+00:00") if text.endswith("Z") else text
    try:
        return datetime.fromisoformat(candidate)
    except Exception:
        return None


def is_string_like_dtype(dtype: pl.DataType | None) -> bool:
    if dtype is None:
        return False
    is_string = getattr(pl.datatypes, "is_string", None)
    if is_string is not None:
        try:
            if is_string(dtype):
                return True
        except Exception:
            pass
    if dtype in {pl.String, pl.Utf8, pl.datatypes.String, pl.datatypes.Utf8}:
        return True
    is_categorical = getattr(pl.datatypes, "is_categorical", None)
    if is_categorical is not None:
        try:
            if is_categorical(dtype):
                return True
        except Exception:
            pass
    dtype_name = type(dtype).__name__.lower()
    return "categorical" in dtype_name or "enum" in dtype_name


def string_like_columns(schema: Mapping[str, pl.DataType] | None) -> list[str]:
    if not schema:
        return []
    return [name for name, dtype in schema.items() if is_string_like_dtype(dtype)]


def suggest_null_tokens_from_slice(
    table_slice: Any,
    *,
    tokens: Sequence[str] | None = None,
) -> list[str]:
    present: set[str] = set()
    columns = getattr(table_slice, "columns", ())
    for column in columns:
        for value in getattr(column, "values", ()):
            normalized = _normalize_sample_value(value)
            if normalized is None:
                continue
            present.add(normalized)
    if not present:
        return []
    token_list = DEFAULT_NULL_TOKENS if tokens is None else tokens
    return [token for token in token_list if normalize_null_token(token) in present]


def _normalize_sample_value(value: object) -> str | None:
    if value is None:
        return None
    if isinstance(value, bytes):
        text = value.decode("utf-8", errors="replace")
    elif isinstance(value, str):
        text = value
    else:
        return None
    return text.strip().casefold()
