"""
Kami - a friendly machine-learning library for beginners.

Kami doesn't replace scikit-learn. It wraps it and does two things sklearn won't:
  1. Catches the classic beginner mistakes for you (and explains them in plain English).
  2. Teaches you what's happening as it happens, so you get smarter while you use it.

New here? Run  kami.info()  for a quick getting-started guide.
Got a data file? Run  df = kami.load("yourfile.xlsx")  - it opens CSV, Excel, JSON,
Parquet, and more, and tells you what to install if a format needs a helper package.
"""

from __future__ import annotations

import inspect
import os
import re
import sys
import textwrap
import time

import numpy as np
import pandas as pd
from sklearn.model_selection import (
    train_test_split,
    cross_val_score,
    cross_val_predict,
    StratifiedKFold,
    KFold,
)
from sklearn.ensemble import (
    RandomForestClassifier,
    RandomForestRegressor,
    GradientBoostingClassifier,
    GradientBoostingRegressor,
)
from sklearn.linear_model import LogisticRegression, LinearRegression, Ridge, Lasso
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
from sklearn.svm import SVC, SVR
from sklearn.naive_bayes import GaussianNB
from sklearn.neural_network import MLPClassifier, MLPRegressor
from sklearn.metrics import accuracy_score, r2_score, mean_squared_error


# --- how much does Kami explain? ---------------------------------------------
_LEVELS = {"silent": 0, "guide": 1, "teacher": 2}


# --- typewriter effect (optional - Kami "speaks" one character at a time) -----
_TYPEWRITER = False     # is the effect on?
_TYPE_SPEED = 0.03      # seconds per character (smaller = faster)


def set_typing(on: bool = True, speed: float = 0.03) -> None:
    """Turn Kami's typewriter effect on/off, and set its speed (seconds per character).

    Example:
        kami.set_typing(True)         # on, at the default speed
        kami.set_typing(True, 0.01)   # on, but faster
        kami.set_typing(False)        # back to instant
    """
    global _TYPEWRITER, _TYPE_SPEED
    _TYPEWRITER = on
    _TYPE_SPEED = speed


def _emit(message: str) -> None:
    """Print one line - as a typewriter if enabled and we're in a real terminal."""
    if _TYPEWRITER and sys.stdout.isatty():
        for ch in message:
            print(ch, end="", flush=True)
            time.sleep(_TYPE_SPEED)
        print()
    else:
        print(message)


def _say(message: str, explain: str, needed: int) -> None:
    """Print a message only when the chosen explain level is high enough."""
    if _LEVELS.get(explain, 1) >= needed:
        _emit(message)


def _wrap(text: str, indent: str = "      ", width: int = 78, first: str = "") -> str:
    """Wrap a long prose message so no line runs off the side of the screen.

    `first` is the indent for the first line; `indent` is for wrapped continuation
    lines (so they line up neatly under the text).
    """
    return textwrap.fill(
        text.strip(),
        width=width,
        initial_indent=first,
        subsequent_indent=indent,
        break_long_words=False,
        break_on_hyphens=False,
    )


def _in_jupyter() -> bool:
    """True if we're running inside a Jupyter/IPython notebook (so rich tables render)."""
    try:
        from IPython import get_ipython
        return get_ipython() is not None
    except Exception:
        return False


def _check_explain(explain: str) -> str:
    if explain not in _LEVELS:
        raise ValueError(
            f"explain must be one of {list(_LEVELS)}, but you passed '{explain}'."
        )
    return explain


_LAST_DF = None
_OMITTED = object()   # marker for "the caller didn't pass a dataframe at all"


def _use_df(data):
    """Remember a passed DataFrame (used after load), returning it unchanged."""
    global _LAST_DF
    if isinstance(data, pd.DataFrame):
        _LAST_DF = data
    return data


def _resolve_df(data, func: str):
    """Resolve the dataframe for look()/train().

    - omitted entirely  -> reuse the last dataframe Kami saw (the convenience case)
    - a real DataFrame  -> use it and remember it
    - an explicit None  -> leave it for _require_dataframe to explain the overwrite trap
    """
    global _LAST_DF
    if data is _OMITTED:
        if isinstance(_LAST_DF, pd.DataFrame):
            return _LAST_DF
        raise ValueError(
            f"{func} needs a dataset, and I don't have one remembered yet.\n"
            "   Load one first, then call it:\n"
            "       df = kami.load('yourfile.csv')\n"
            f"       {func}(df)"
        )
    if isinstance(data, pd.DataFrame):
        _LAST_DF = data
    return data


def _require_dataframe(data, func: str) -> None:
    """Raise a clear, recovery-oriented error if `data` is not a usable DataFrame.

    The common beginner trap is overwriting df with the output of a function that
    only shows information (look/audit/charts return nothing), which sets df to None.
    """
    if data is None:
        raise ValueError(
            f"Your data is empty (None), so {func} can't run.\n\n"
            "   Most likely df got overwritten. Writing  df = kami.look(df)  (or\n"
            "   df = kami.audit(df), or df = a chart) sets df to None, because those\n"
            "   SHOW you information instead of handing your data back.\n\n"
            "   Fix - reload your data, then use that variable as-is (don't reassign it):\n"
            "       df = kami.load('yourfile.csv')\n"
            "       kami.look(df)          # just look - do NOT write  df = kami.look(df)\n"
            "       result = kami.train(df, target='...')"
        )
    if not isinstance(data, pd.DataFrame):
        raise ValueError(
            f"{func} needs your data as a table (a pandas DataFrame), usually the df you "
            f"got back from kami.load(...).\n"
            f"   You passed a {type(data).__name__} instead."
        )


def _step(number: int, title: str, explain: str) -> None:
    """Print a blank line + a section header, so the output is easy to follow."""
    _say("", explain, 1)
    _say(f"--- STEP {number} of 4: {title} ---", explain, 1)


# --- little helpers that do the "smart" work (no AI needed) ------------------


_CURRENCY_CHARS = "$\u20ac\u00a3\u00a5\u20b9\u20bd"          # $ EUR GBP JPY INR RUB
_CURRENCY_CODES = {"usd", "eur", "gbp", "jpy", "cad", "aud", "chf", "cny",
                   "inr", "rub", "brl", "mxn", "krw", "sgd", "hkd", "nzd"}
_NULL_TOKENS = {"", "na", "n/a", "null", "none", "nan", "nat", "-", "--", ".",
                "?", "??", "missing", "unknown", "<na>", "#n/a", "n.a.", "tbd"}
_THOUSANDS_RE = re.compile(r"^\d{1,3}(,\d{3})+$")           # 1,234  /  12,345,678
_THOUSANDS_DEC_RE = re.compile(r"^\d{1,3}(,\d{3})+\.\d+$")  # 1,234.56
_DECIMAL_COMMA_RE = re.compile(r"^\d+,\d{1,2}$")            # 49,99  (european decimal)
_NUMERIC_TEXT_MIN = 0.90   # a text column converts to numbers if >= this share parse


def _strip_number_decorations(series: pd.Series) -> pd.Series:
    """Strip currency symbols/codes, thousands commas, %, accounting parentheses and
    spaces so numbers hidden in text can be parsed. Conservative: '1,234' loses the
    comma (thousands) but '49,99' becomes '49.99' (decimal comma)."""
    def one(x):
        if not isinstance(x, str):
            return x
        t = x.replace("\u00a0", " ").strip()
        if not t:
            return x
        neg = False
        if t.startswith("(") and t.endswith(")"):       # (50) -> -50  (accounting)
            t = t[1:-1].strip()
            neg = True
        parts = t.split()
        if len(parts) >= 2 and parts[-1].lower() in _CURRENCY_CODES:
            t = " ".join(parts[:-1]).strip()
        elif len(parts) >= 2 and parts[0].lower() in _CURRENCY_CODES:
            t = " ".join(parts[1:]).strip()
        if t and t[0] in _CURRENCY_CHARS:
            t = t[1:].strip()
        if t and t[-1] in _CURRENCY_CHARS:
            t = t[:-1].strip()
        if t.endswith("%"):
            t = t[:-1].strip()
        if t.startswith("+"):
            t = t[1:].strip()
        core = t[1:] if t.startswith("-") else t
        if _THOUSANDS_RE.match(core) or _THOUSANDS_DEC_RE.match(core):
            t = t.replace(",", "")
        elif _DECIMAL_COMMA_RE.match(core):
            t = t.replace(",", ".")
        if neg and t and not t.startswith("-"):
            t = "-" + t
        return t
    return series.map(one)


def _coerce_numeric_text(series: pd.Series) -> pd.Series:
    """Best-effort convert a text series to numbers, after stripping symbols like $ , %."""
    return pd.to_numeric(_strip_number_decorations(series), errors="coerce")


def _numeric_text_fraction(series: pd.Series) -> float:
    """Fraction of non-blank values that become real numbers once symbols are stripped.

    On big columns this samples (the per-value parsing is Python-level), so the many
    detection/ranking calls in look/report/recommend stay fast on large datasets.
    """
    nn = series.dropna()
    if len(nn) == 0:
        return 0.0
    if len(nn) > 3000:
        nn = nn.sample(3000, random_state=0)
    return float(_coerce_numeric_text(nn).notna().sum()) / len(nn)


def _detect_task(y: pd.Series, override: str | None) -> str:
    """Guess whether this is classification or regression - just by looking at the target."""
    if override in ("classification", "regression"):
        return override
    if override is not None:
        raise ValueError("task must be 'classification', 'regression', or None.")

    n_unique = y.nunique(dropna=True)
    if not pd.api.types.is_numeric_dtype(y):
        # numbers hidden in text (e.g. "$23,000")? judge by the numbers, not the text
        if not pd.api.types.is_bool_dtype(y) and _numeric_text_fraction(y) >= _NUMERIC_TEXT_MIN:
            y = _coerce_numeric_text(y)
            n_unique = y.nunique(dropna=True)
        else:
            return "classification"
    if pd.api.types.is_float_dtype(y) and n_unique > 15:
        return "regression"
    if n_unique <= 15:
        return "classification"
    return "regression"


def _model_task(name) -> str | None:
    """Which task a model name implies (regression/classification), or None if unknown."""
    if not isinstance(name, str):
        return None
    n = name.strip().lower()
    if n.endswith("regressor") or n in ("linearregression", "ridge", "lasso", "svr"):
        return "regression"
    if n.endswith("classifier") or n in ("logisticregression", "gaussiannb", "svc"):
        return "classification"
    return None


def _interpret_score(task: str, score: float, baseline: float = None):
    """Turn a raw score into a plain-English verdict: (short_label, one-sentence meaning).

    These bands are rough rules of thumb, not hard rules - they're here to give a
    beginner a feel for whether a score is good, not to be the final word.
    """
    if task == "regression":
        if score <= 0:
            return ("No signal", "no better than guessing the average every time")
        if score >= 0.90:
            return ("Excellent", "explains almost all of what makes the target change")
        if score >= 0.75:
            return ("Strong", "explains most of what makes the target change")
        if score >= 0.50:
            return ("Moderate", "explains a fair amount, but misses a lot")
        if score >= 0.25:
            return ("Weak", "captures only a little of what drives the target")
        return ("Very weak", "barely better than guessing the average")
    base = baseline if baseline is not None else 0.0
    gap = score - base
    if gap <= 0.01:
        return ("Weak", f"barely better than always guessing the most common answer ({base:.0%})")
    if gap <= 0.05:
        return ("Moderate", f"a little better than always guessing the most common answer ({base:.0%})")
    if gap <= 0.15:
        return ("Good", "clearly better than just guessing")
    return ("Excellent", "much better than just guessing")


_ID_NAME_HINTS = ("email", "username", "user_name", "phone_number",
                  "phonenumber", "ssn", "uuid", "guid")


def _find_id_like(X: pd.DataFrame) -> list[str]:
    """Find columns that look like row identifiers, not real features."""
    suspects = []
    n = len(X)
    for col in X.columns:
        name = str(col).lower().replace(" ", "")
        looks_named_like_id = (
            name == "id" or name.endswith("_id") or name.endswith("id")
            or any(hint in name for hint in _ID_NAME_HINTS)
        )
        all_values_unique = X[col].nunique(dropna=True) == n
        is_float = pd.api.types.is_float_dtype(X[col])
        looks_like_email = False
        if not pd.api.types.is_numeric_dtype(X[col]) \
                and not pd.api.types.is_datetime64_any_dtype(X[col]):
            vals = X[col].dropna().astype(str)
            if len(vals) and vals.str.contains("@").mean() > 0.8:
                looks_like_email = True
        if looks_named_like_id or looks_like_email or (all_values_unique and not is_float):
            suspects.append(col)
    return suspects


def _name_leak_suspects(target, feature_cols) -> list:
    """Columns whose NAME looks derived from the target (e.g. target 'price' -> 'price_k')."""
    t = str(target).lower()
    out = []
    for c in feature_cols:
        cl = str(c).lower()
        if cl == t:
            continue
        tokens = cl.replace("-", " ").replace("_", " ").split()
        if t in tokens or (len(t) >= 4 and (cl.startswith(t) or cl.endswith(t))):
            out.append(c)
    return out


def _text_columns(X: pd.DataFrame) -> list:
    """Non-numeric, non-bool, non-date columns (the ones that need encoding)."""
    return [
        c
        for c in X.columns
        if not pd.api.types.is_numeric_dtype(X[c])
        and not pd.api.types.is_bool_dtype(X[c])
        and not pd.api.types.is_datetime64_any_dtype(X[c])
    ]


def _safe_to_datetime(series: pd.Series) -> pd.Series:
    """Coerce to datetime, surviving pandas' 'mixed timezones' and other parse errors.

    Messy real-world columns can make pd.to_datetime RAISE even with errors='coerce'
    (e.g. when values look like timestamps with different timezone offsets). This always
    returns a tz-naive datetime Series, with NaT where a value can't be parsed.
    """
    import warnings as _w
    with _w.catch_warnings():
        _w.simplefilter("ignore")
        try:
            dt = pd.to_datetime(series, errors="coerce")
        except Exception:
            try:
                dt = pd.to_datetime(series, errors="coerce", utc=True)
            except Exception:
                return pd.Series(pd.NaT, index=series.index)
    if getattr(dt.dtype, "tz", None) is not None:
        try:
            dt = dt.dt.tz_localize(None)
        except Exception:
            try:
                dt = dt.dt.tz_convert(None)
            except Exception:
                return pd.Series(pd.NaT, index=series.index)
    return dt


def _add_date_parts(X: pd.DataFrame, cols) -> pd.DataFrame:
    """Replace each named date column with numeric year/month/day/day-of-week columns."""
    X = X.copy()
    for col in cols:
        if col not in X.columns:
            continue
        dt = _safe_to_datetime(X[col])
        X[f"{col}_year"] = dt.dt.year
        X[f"{col}_month"] = dt.dt.month
        X[f"{col}_day"] = dt.dt.day
        X[f"{col}_dayofweek"] = dt.dt.dayofweek
        X = X.drop(columns=[col])
    return X


def _engineer_dates(X: pd.DataFrame, explain: str):
    """Turn date columns into numbers a model can learn from. Returns (X, date_cols)."""
    date_cols = [c for c in X.columns if pd.api.types.is_datetime64_any_dtype(X[c])]
    if not date_cols:
        return X, []
    X = _add_date_parts(X, date_cols)
    _say(
        _wrap(
            f"Kami: Turned date column(s) {date_cols} into numbers the model can use "
            f"(year, month, day, and day-of-week)."
        ),
        explain, 1,
    )
    _say(
        _wrap(
            "Models can't read a raw date, but the pieces of one - the month, the weekday - "
            "often carry the real pattern (think seasonality, or weekends vs weekdays).",
            first="    ", indent="    ",
        ),
        explain, 2,
    )
    return X, date_cols


def _handle_missing(X: pd.DataFrame, explain: str, target: str):
    """Fill missing values: numbers with the median, text with the word 'Missing'.

    Announces exactly which columns were filled and with what, so nothing is hidden.
    Returns (filled_X, fill_values) where fill_values maps column -> value used.
    """
    per_col = X.isna().sum()
    cols = per_col[per_col > 0]
    if len(cols) == 0:
        return X, {}
    X = X.copy()
    fill_values = {}
    notes = []
    for col, n in cols.items():
        if pd.api.types.is_numeric_dtype(X[col]):
            fill = X[col].median()
            X[col] = X[col].fillna(fill)
            fill_values[col] = float(fill) if pd.notna(fill) else 0
            notes.append(f"      - {col}: {int(n)} blank(s) filled with the median ({fill:g})")
        else:
            X[col] = X[col].fillna("Missing")
            fill_values[col] = "Missing"
            notes.append(f'      - {col}: {int(n)} blank(s) filled with "Missing" (its own category)')
    _say(
        'Kami: Filled the missing values for you (numbers with the median, text with "Missing"):',
        explain,
        1,
    )
    for note in notes:
        _say(note, explain, 1)
    _say("      Prefer to drop the rows with blanks instead? Re-run with this exact line:", explain, 1)
    _say(f"          result = kami.train(df, target='{target}', missing='drop')", explain, 1)
    _say("      (To see exactly what was filled in, check result.fill_values after training.)", explain, 1)
    _say(
        _wrap(
            "Filling keeps all your rows - usually more for the model to learn from - but it "
            "does put in values that weren't really there. The more of a column is blank, the more "
            "that matters, so it's worth a glance at the counts above.",
            first="    ", indent="    ",
        ),
        explain,
        2,
    )
    return X, fill_values


def _apply_missing_policy(X, y, missing, explain, target):
    """Handle missing values in the FEATURES according to the user's choice.

    missing="fill" -> numbers -> median, text -> "Missing"  (the default)
    missing="drop" -> remove rows that have any missing value
    missing="ask"  -> stop and make the user decide
    Returns (X, y, fill_values).
    """
    if missing not in ("ask", "fill", "drop"):
        raise ValueError(
            f"missing must be 'ask', 'fill', or 'drop', but you passed '{missing}'."
        )

    # a column that is 100% empty carries no information and can't be filled
    # (it has no median), so drop it up front - whatever the policy is.
    per_col = X.isna().sum()
    fully_empty = [c for c in X.columns if int(per_col[c]) == len(X)]
    if fully_empty:
        X = X.drop(columns=fully_empty)
        _say(
            f"Kami: Dropped column(s) {fully_empty} - they're completely empty, "
            f"so there's nothing in them to learn from.",
            explain,
            1,
        )
        per_col = X.isna().sum()

    cols_with_missing = per_col[per_col > 0]
    if len(cols_with_missing) == 0:
        return X, y, {}

    if missing == "ask":
        lines = "\n".join(
            f"   - {col}: {int(n)} missing" for col, n in cols_with_missing.items()
        )
        raise ValueError(
            "Kami found missing values and needs YOU to decide how to handle them.\n"
            "How they're handled changes your results, so this choice is yours, not mine:\n\n"
            f"{lines}\n\n"
            "Copy ONE of these into your train() call:\n\n"
            f'   result = kami.train(df, target="{target}", missing="fill")\n'
            '       -> fills the blanks (numbers with the median, text with "Missing").\n'
            "          Fast, but it can invent data that was never really there.\n\n"
            f'   result = kami.train(df, target="{target}", missing="drop")\n'
            "       -> removes the rows that have a missing value."
        )

    if missing == "fill":
        X, fill_values = _handle_missing(X, explain, target)
        return X, y, fill_values

    # missing == "drop"
    before = len(X)
    keep = ~X.isna().any(axis=1)
    X, y = X[keep], y[keep]
    removed = before - len(X)
    if len(X) == 0:
        raise ValueError(
            "Dropping rows with missing values removed ALL of your data. "
            "Try missing='fill' instead, or clean the data first."
        )
    _say(
        f"Kami: Dropped {removed} row(s) that had missing values "
        f"(you chose missing=\"drop\").",
        explain,
        1,
    )
    return X, y, {}


def _apply_cat_grouping(X: pd.DataFrame, cat_keep: dict) -> pd.DataFrame:
    """Collapse any category not in the kept list (learned at training time) to 'Other'.
    Used so new/rare categories at predict time map the same way they did in training."""
    for col, keep in cat_keep.items():
        if col in X.columns:
            X[col] = X[col].where(X[col].isin(keep), other="Other")
    return X


def _encode_categoricals(X: pd.DataFrame, explain: str):
    """One-hot encode text columns. Very high-cardinality columns are first reduced to
    their most common categories (the rest grouped as 'Other') so the feature matrix
    doesn't explode. Returns (encoded_X, cat_keep) where cat_keep records what was kept."""
    X = X.copy()
    bool_cols = [c for c in X.columns if pd.api.types.is_bool_dtype(X[c])]
    for col in bool_cols:
        X[col] = X[col].astype(int)

    text_cols = _text_columns(X)
    if not text_cols:
        return X, {}
    cat_keep = {}
    top_n = 30
    for col in text_cols:
        n_distinct = int(X[col].nunique(dropna=True))
        if n_distinct > 50:
            keep = list(X[col].value_counts().head(top_n).index)
            cat_keep[col] = keep
            X[col] = X[col].where(X[col].isin(keep), other="Other")
            _say(
                _wrap(
                    f"Kami: Column '{col}' had {n_distinct} categories - I kept the "
                    f"{top_n} most common and grouped the rest as 'Other', so it doesn't "
                    f"explode into hundreds of columns.",
                    first="", indent="      ",
                ),
                explain,
                1,
            )
    X = pd.get_dummies(X, columns=text_cols, dtype=int)
    _say(
        _wrap(
            f"Kami: Turned text column(s) {text_cols} into numbers so the model can use them.",
            first="", indent="      ",
        ),
        explain,
        1,
    )
    _say(
        _wrap(
            'Models do maths, not words. "One-hot encoding" makes a yes/no column '
            "for each category - e.g. plan='Pro' becomes plan_Pro = 1.",
            first="    ", indent="    ",
        ),
        explain,
        2,
    )
    return X, cat_keep


def _check_size(n_rows: int, explain: str) -> None:
    if n_rows < 50:
        _say(
            _wrap(
                f"Kami: Only {n_rows} rows of data. That's quite small - the model "
                f"may be unreliable. Treat results as a rough first look.",
                first="", indent="      ",
            ),
            explain,
            1,
        )


def _check_imbalance(y: pd.Series, explain: str) -> None:
    counts = y.value_counts(normalize=True)
    biggest = counts.iloc[0]
    if biggest > 0.80:
        top = counts.index[0]
        _say(
            _wrap(
                f"Kami: Your classes are imbalanced - '{top}' is {biggest:.0%} of the "
                f"data. Accuracy can look great just by always guessing '{top}'."
            ),
            explain,
            1,
        )
        _say(
            _wrap(
                "With imbalance, high accuracy can be a trap. Later, look at "
                "precision/recall per class, not just the overall score.",
                first="    ", indent="    ",
            ),
            explain,
            2,
        )


# --- the model menu: by family, by name, with one recommended default --------
# Models are grouped by family for display, flattened for name lookup.
_MODEL_GROUPS = {
    "regression": {
        "Linear models": ["LinearRegression", "Ridge", "Lasso"],
        "Neighbours": ["KNeighborsRegressor"],
        "Support vector": ["SVR"],
        "Tree-based & ensemble": [
            "DecisionTreeRegressor",
            "RandomForestRegressor",
            "GradientBoostingRegressor",
        ],
        "Neural network": ["MLPRegressor"],
    },
    "classification": {
        "Linear models": ["LogisticRegression"],
        "Naive Bayes": ["GaussianNB"],
        "Neighbours": ["KNeighborsClassifier"],
        "Support vector": ["SVC"],
        "Tree-based & ensemble": [
            "DecisionTreeClassifier",
            "RandomForestClassifier",
            "GradientBoostingClassifier",
        ],
        "Neural network": ["MLPClassifier"],
    },
}

_MODELS_BY_NAME = {
    "regression": {
        "LinearRegression": LinearRegression,
        "Ridge": Ridge,
        "Lasso": Lasso,
        "KNeighborsRegressor": KNeighborsRegressor,
        "SVR": SVR,
        "DecisionTreeRegressor": DecisionTreeRegressor,
        "RandomForestRegressor": RandomForestRegressor,
        "GradientBoostingRegressor": GradientBoostingRegressor,
        "MLPRegressor": MLPRegressor,
    },
    "classification": {
        "LogisticRegression": LogisticRegression,
        "GaussianNB": GaussianNB,
        "KNeighborsClassifier": KNeighborsClassifier,
        "SVC": SVC,
        "DecisionTreeClassifier": DecisionTreeClassifier,
        "RandomForestClassifier": RandomForestClassifier,
        "GradientBoostingClassifier": GradientBoostingClassifier,
        "MLPClassifier": MLPClassifier,
    },
}

_RECOMMENDED = {
    "regression": "RandomForestRegressor",
    "classification": "RandomForestClassifier",
}

# a few models need non-default settings to behave well for beginners
_MODEL_KWARGS = {
    "MLPRegressor": {"max_iter": 1000},
    "MLPClassifier": {"max_iter": 1000},
    "LogisticRegression": {"max_iter": 1000},
}


def _instantiate(model_class, random_state):
    """Create a model, applying any helpful defaults and random_state if supported."""
    kwargs = dict(_MODEL_KWARGS.get(model_class.__name__, {}))
    if "random_state" in inspect.signature(model_class).parameters:
        kwargs["random_state"] = random_state
    return model_class(**kwargs)


_SCALE_MODELS = {"KNeighborsRegressor", "KNeighborsClassifier", "SVR", "SVC",
                 "MLPRegressor", "MLPClassifier"}


def _maybe_scale(estimator, explain, announce=True):
    """Wrap distance-based models (KNN/SVM/MLP) in a StandardScaler pipeline so columns
    on different scales can't dominate just by being bigger. Others pass through."""
    if type(estimator).__name__ in _SCALE_MODELS:
        from sklearn.preprocessing import StandardScaler
        from sklearn.pipeline import Pipeline
        if announce:
            _say(_wrap(
                f"Kami: Scaling your number columns to a common range first - "
                f"{type(estimator).__name__} compares rows by distance, so otherwise a "
                f"column with bigger numbers would drown out the rest.",
                first="", indent="      "), explain, 1)
        return Pipeline([("scale", StandardScaler()), ("model", estimator)])
    return estimator


def _model_label(model) -> str:
    """Friendly model name, seeing through a scaling Pipeline to the real estimator."""
    try:
        from sklearn.pipeline import Pipeline
        if isinstance(model, Pipeline):
            return type(model.named_steps["model"]).__name__
    except Exception:
        pass
    return type(model).__name__


def _resolve_model(model, task, random_state, explain, target, missing,
                   n_rows=None, n_text=None, n_numeric=None):
    """Turn the user's model choice into a ready-to-train model.

    `model` can be:
      - None           -> Kami picks the recommended default for you
      - a name string  -> e.g. "Ridge" (the names look() lists)
      - a real model   -> any sklearn-style object with .fit()/.predict()
    """
    valid = _MODELS_BY_NAME[task]

    if model is None:
        name = _RECOMMENDED[task]
        _say(
            _wrap(
                f"Kami: No model chosen, so I'll start you with {name} "
                f"(a strong, forgiving default)."
            ),
            explain,
            1,
        )
        # a plain-English reason that actually reflects THIS dataset
        reasons = []
        if n_text and n_numeric:
            reasons.append("your data mixes text and number columns, and it handles both together")
        elif n_text:
            reasons.append("your text columns become usable once encoded, and it copes with them well")
        elif n_numeric:
            reasons.append("it works well on number columns like yours")
        if n_rows is not None:
            if n_rows < 500:
                reasons.append("your dataset is small, where a forgiving model that rarely "
                               "overfits is a safe bet")
            elif n_rows >= 50000:
                reasons.append("your dataset is large enough for it to find rich patterns")
        reason_text = "; ".join(reasons) if reasons else "it's a dependable all-rounder"
        verb = "predict a number" if task == "regression" else "sort rows into groups"
        _say(
            _wrap(
                f"Why this one? Because {reason_text}. For your job ({verb}) it "
                f"captures non-linear patterns, rarely overfits, and needs no tuning - a "
                f"trustworthy first model. To compare it against others, try "
                f"kami.bakeoff(df, target='{target}').",
                first="      ", indent="      ",
            ),
            explain,
            1,
        )
        example = "LinearRegression" if task == "regression" else "LogisticRegression"
        _say(
            "      To use a different model, add model='...' to your call, like this:\n"
            f"          result = kami.train(df, target='{target}', model='{example}')\n"
            f"      Run kami.models('{task}') to see every model name you can pick.",
            explain,
            1,
        )
        _say(
            _wrap(
                "Deeper reason: a single decision tree asks yes/no questions to split the "
                "data; a random forest grows many such trees on random slices and averages "
                "them, which cancels out their individual mistakes. That averaging is why it "
                "rarely overfits and needs little tuning.",
                first="    ", indent="    ",
            ),
            explain,
            2,
        )
        return _maybe_scale(_instantiate(valid[name], random_state), explain)

    if isinstance(model, str):
        if model not in valid:
            raise ValueError(
                f"'{model}' isn't a model name I know for a {task} problem.\n"
                f"   Choose one of: {list(valid)}"
            )
        _say(f"Kami: Using {model} (the model you chose).", explain, 1)
        return _maybe_scale(_instantiate(valid[model], random_state), explain)

    _say(f"Kami: Using the {_model_label(model)} you passed in.", explain, 1)
    return model


# --- the result you get back -------------------------------------------------


def _example_scalar(s: pd.Series):
    """One clean, native-Python example value from a column (for predict() templates)."""
    nn = s.dropna()
    if len(nn) == 0:
        return 0
    v = nn.iloc[0]
    if isinstance(v, pd.Timestamp):
        return str(v.date())                 # clean 'YYYY-MM-DD' for the predict template
    if hasattr(v, "item"):
        v = v.item()
    if isinstance(v, float) and v.is_integer():
        return int(v)
    if isinstance(v, float):
        return round(v, 2)
    if isinstance(v, (int, str, bool)):
        return v
    return str(v)


def _input_example_text(input_example: dict, max_cols: int = 6) -> str:
    """Render an input example as a copy-paste Python dict literal (truncated if wide)."""
    items = list(input_example.items())
    body = ", ".join(f"{k!r}: {v!r}" for k, v in items[:max_cols])
    if len(items) > max_cols:
        body += ", ..."
    return "{" + body + "}"


def _edit_lines(input_columns: list, input_example: dict) -> str:
    """One or two concrete 'change this attribute' lines, preferring numeric columns."""
    numeric = [
        c for c in input_columns
        if isinstance(input_example.get(c), (int, float))
        and not isinstance(input_example.get(c), bool)
    ]
    pick = (numeric or list(input_columns))[:2]
    lines = []
    for i, c in enumerate(pick):
        v = input_example.get(c, 0)
        comment = "      # <- change this to whatever you want to test" if i == 0 else ""
        lines.append(f"          new_row[{c!r}] = {v!r}{comment}")
    return "\n".join(lines)


def _predict_example(input_columns: list, input_example: dict) -> str:
    """Numbered, copy-paste steps showing how to predict on a new row.

    For a few columns it shows a full dict you edit in place. For many columns it
    hands back an editable template (result.example_row()) and shows how to change
    any attribute by name. Always ends with print(...) so the prediction is visible.
    """
    predict_step = (
        "  3.  Predict - Kami shows the result in a highlighted block\n"
        "          predictions = kami.predict(result, new_row)"
    )
    if len(input_columns) <= 6:
        cols = ", ".join(str(c) for c in input_columns)
        return (
            "do this:\n\n"
            f"  1.  Build a row with your columns ({cols})\n"
            f"          new_row = {_input_example_text(input_example)}\n\n"
            "  2.  Change any values you want to test\n"
            "          (just edit the numbers/text inside new_row above)\n\n"
            f"{predict_step}"
        )
    edits = _edit_lines(input_columns, input_example)
    return (
        f"you have {len(input_columns)} input columns. Do this:\n\n"
        "  1.  Get a row template you can edit (all your columns, already filled in)\n"
        "          new_row = result.example_row()\n\n"
        "  2.  Change any attribute you want to test (any column works the same way)\n"
        f"{edits}\n\n"
        f"{predict_step}"
    )


class KamiResult:
    """What kami.train() hands back: a trained model plus everything you need to use it."""

    def __init__(
        self,
        model,
        task,
        model_name,
        score,
        feature_columns,
        target,
        id_cols,
        rmse=None,
        input_columns=None,
        input_example=None,
        fill_values=None,
        eval_actual=None,
        eval_pred=None,
        impute=None,
        text_cols=None,
        date_cols=None,
        cat_keep=None,
        n_rows=None,
        missing=None,
        validation="holdout",
        n_folds=None,
        random_state=None,
        test_size=None,
        baseline=None,
        confidence=None,
        confidence_reason=None,
    ):
        self.raw = model
        self.task = task
        self.model_name = model_name
        self.score = score
        self.rmse = rmse
        self.feature_columns = feature_columns
        self.target = target
        self._id_cols = id_cols
        self.input_columns = input_columns or []
        self._input_example = input_example or {}
        self.fill_values = fill_values or {}
        self._eval_actual = eval_actual      # true values from the held-out test rows
        self._eval_pred = eval_pred          # what the model predicted for those rows
        # preprocessing learned during training, reused verbatim at predict time so
        # the two never drift apart (e.g. predict fills blanks with the TRAINING median,
        # not a median recomputed from whatever new rows happen to come in):
        self._impute = impute or {}          # column -> value to fill blanks with
        self._text_cols = text_cols or []    # columns that were one-hot encoded
        self._cat_keep = cat_keep or {}      # high-card columns -> kept categories
        self._date_cols = date_cols or []    # date columns expanded into year/month/...
        # how the model was built (for result.summary() / reproducibility):
        self.n_rows = n_rows
        self.missing = missing
        self.validation = validation
        self.n_folds = n_folds
        self.random_state = random_state
        self.test_size = test_size
        self.baseline = baseline
        self.confidence = confidence
        self.confidence_reason = confidence_reason

    def summary(self) -> None:
        """Print a plain-English recap of how this model was built.

        Handy for your notes and for reproducibility - it lists the target, model,
        data size, how missing values were handled, how it was validated, and the score.
        """
        if self.missing == "drop":
            miss = "rows with blanks were dropped"
        elif self.missing == "fill":
            miss = "blanks filled (median for numbers, most common for text)"
        else:
            miss = str(self.missing)
        if self.validation == "cross":
            val = f"{self.n_folds}-fold cross-validation"
        else:
            pct = f"{self.test_size:.0%}" if self.test_size is not None else "20%"
            val = f"single train/test split (held out {pct})"

        rows = [
            ("Predicting", str(self.target)),
            ("Problem type", "regression (a number)" if self.task == "regression"
                             else "classification (a category)"),
            ("Model", self.model_name),
            ("Rows used", f"{self.n_rows:,}" if self.n_rows is not None else "n/a"),
            ("Input columns", str(len(self.input_columns))),
            ("Missing values", miss),
            ("Validation", val),
            ("Random state", str(self.random_state)),
        ]
        if self.confidence is not None:
            rows.append(("Confidence", f"{self.confidence} ({self.confidence_reason})"))
        if self.task == "regression":
            rows.append(("R2 score", f"{self.score:.3f}" if self.score is not None else "n/a"))
            if self.rmse is not None:
                rows.append(("RMSE", f"{self.rmse:.3f}"))
        else:
            rows.append(("Accuracy", f"{self.score:.1%}" if self.score is not None else "n/a"))
            if self.baseline is not None:
                rows.append(("Baseline", f"{self.baseline:.1%}"))
        _show_scorecard(rows, "guide", title="MODEL SUMMARY")
        return None

    def example_row(self, show: bool = True) -> dict:
        """A ready-to-edit template row: your input columns with sample values filled in.

        By default it PRINTS the template (so you can see every column and its value),
        and also returns it as a dict you can edit and pass to predict:

            new_row = result.example_row()
            new_row['height'] = 5.0      # change any column by name
            kami.predict(result, new_row)

        Pass show=False to get the dict back silently.
        """
        row = dict(self._input_example)
        if show:
            _emit(_wrap(
                f"Kami: Here's your editable row template - {len(row)} column(s), already "
                "filled with example values. Change any value with new_row['column'] = ..., "
                "then run kami.predict(result, new_row).",
            ))
            _emit("")
            if row:
                width = min(max(len(str(k)) for k in row), 28)
                for k, v in row.items():
                    _emit(f"      new_row[{k!r}]{' ' * max(0, width - len(str(k)))} = {v!r}")
            _emit("")
            _emit(_wrap(
                "To see how good this model is overall, run kami.plot_predictions(result); "
                "kami.feature_importance(result) shows which columns it leaned on most.",
                first="      ", indent="      ",
            ))
        return row

    def _predict_help(self) -> str:
        """The friendly 'here's how to pass new data' message."""
        return (
            "Kami needs the new data as a row of named columns (a dict, or a DataFrame), "
            "not loose values.\n"
            "   To predict on one new row, "
            + _predict_example(self.input_columns, self._input_example)
        )

    def _transform_new(self, df: pd.DataFrame) -> pd.DataFrame:
        """Apply the SAME preprocessing the model was trained with, to new rows.

        Uses the values learned during training (which columns were dropped, what to
        fill blanks with, which columns were one-hot encoded, the final column order),
        so prediction never drifts from training.
        """
        X = df.copy()
        X = X.drop(columns=[c for c in self._id_cols if c in X.columns], errors="ignore")
        if self.target in X.columns:
            X = X.drop(columns=[self.target])
        # expand the same date columns into year/month/day/day-of-week
        if self._date_cols:
            X = _add_date_parts(X, self._date_cols)
        # fill blanks with the values learned at training time (no recomputation here)
        for col in X.columns:
            if not X[col].isna().any():
                continue
            if col in self._impute:
                X[col] = X[col].fillna(self._impute[col])
            elif pd.api.types.is_numeric_dtype(X[col]):
                present = X[col].dropna()
                X[col] = X[col].fillna(present.median() if len(present) else 0)
            else:
                X[col] = X[col].fillna("Missing")
        for col in X.columns:
            if pd.api.types.is_bool_dtype(X[col]):
                X[col] = X[col].astype(int)
        present_text = [c for c in self._text_cols if c in X.columns]
        if present_text:
            X = _apply_cat_grouping(X, self._cat_keep)   # rare/unseen categories -> "Other"
            X = pd.get_dummies(X, columns=present_text, dtype=int)
        return X.reindex(columns=self.feature_columns, fill_value=0)

    def predict(self, new_data, show: bool = True):
        """Predict on new rows. Accepts a dict (one row), a list of dicts, or a DataFrame.

        Kami re-applies the same prep it used during training, so you only pass the
        original input columns - no need to encode or clean anything yourself. The
        prediction is shown in a highlighted block; pass show=False to skip that
        (handy when predicting many rows inside a loop).
        """
        if isinstance(new_data, dict):
            new_data = pd.DataFrame([new_data])
        elif isinstance(new_data, list) and new_data and isinstance(new_data[0], dict):
            new_data = pd.DataFrame(new_data)
        elif not isinstance(new_data, pd.DataFrame):
            raise ValueError(self._predict_help())

        X = self._transform_new(new_data)
        preds = self.raw.predict(X)
        if show:
            proba = None
            if self.task == "classification" and hasattr(self.raw, "predict_proba"):
                try:
                    proba = self.raw.predict_proba(X).max(axis=1)
                except Exception:
                    proba = None
            _show_prediction(self, preds, proba)
        return preds

    def __repr__(self):
        metric = "accuracy" if self.task == "classification" else "R2"
        line = f"<Kami {self.task} model: {self.model_name} | {metric}={self.score:.3f}"
        if self.rmse is not None:
            line += f" | RMSE={self.rmse:.3f}"
        return line + f" | predicting '{self.target}'>"


# --- the functions you actually call ----------------------------------------


# file types kami.load() understands: extension -> (pandas reader, label, package needed, default args)
_READERS = {
    ".csv": ("read_csv", "CSV", None, {}),
    ".txt": ("read_csv", "text", None, {}),
    ".tsv": ("read_csv", "TSV", None, {"sep": "\t"}),
    ".xlsx": ("read_excel", "Excel", "openpyxl", {}),
    ".xls": ("read_excel", "Excel", "xlrd", {}),
    ".json": ("read_json", "JSON", None, {}),
    ".parquet": ("read_parquet", "Parquet", "pyarrow", {}),
    ".feather": ("read_feather", "Feather/Arrow", "pyarrow", {}),
    ".orc": ("read_orc", "ORC", "pyarrow", {}),
    ".h5": ("read_hdf", "HDF5", "tables", {}),
    ".hdf5": ("read_hdf", "HDF5", "tables", {}),
    ".xml": ("read_xml", "XML", "lxml", {}),
}


def load(path: str, **kwargs) -> pd.DataFrame:
    """Load a data file into a DataFrame, picking the right reader from the file name.

    Handles: .csv .tsv .txt .xlsx .xls .json .parquet .feather .orc .h5/.hdf5 .xml
    Any extra keyword arguments pass straight through to the pandas reader, e.g.
        df = kami.load("book.xlsx", sheet_name="Sales")

    If a format needs an extra package you don't have, Kami tells you exactly which
    one to install.
    """
    ext = os.path.splitext(path)[1].lower()

    # formats that aren't flat tables - explain honestly instead of pretending
    if ext == ".sql":
        raise ValueError(
            "A .sql file is a list of database commands, not a table of data, so there's "
            "nothing for Kami to load directly.\n"
            "   To pull data out of SQL you connect to a database and run a query:\n"
            "       import sqlite3, pandas as pd\n"
            "       conn = sqlite3.connect('my_database.db')\n"
            "       df = pd.read_sql('SELECT * FROM my_table', conn)\n"
            "   (A .sql dump first has to be loaded INTO a database, then queried.)"
        )
    if ext in (".nc", ".nc4", ".cdf", ".netcdf"):
        raise ValueError(
            "NetCDF files hold multi-dimensional scientific grids (think climate data: "
            "latitude x longitude x time x value), not flat rows and columns, so they "
            "don't fit Kami's table-shaped world. The 'xarray' library is built for those."
        )

    if ext not in _READERS:
        nice = ", ".join(sorted(_READERS))
        raise ValueError(
            f"Kami doesn't recognise the file type '{ext or '(no extension)'}'.\n"
            f"   It can load: {nice}\n"
            "   If your file really is one of these but named differently, rename it and try again."
        )

    reader_name, label, needs, defaults = _READERS[ext]
    reader = getattr(pd, reader_name, None)
    if reader is None:
        raise ImportError(
            f"Your version of pandas has no {reader_name}() for {label} files. "
            "Updating pandas (pip install -U pandas) should add it."
        )

    try:
        result = reader(path, **{**defaults, **kwargs})
    except FileNotFoundError:
        raise FileNotFoundError(
            f"Kami couldn't find a file at '{path}'. Check the spelling and the folder it's in.\n"
            "   Tip: in a notebook, run  import os; os.getcwd()  to see which folder Kami "
            "is looking in, or pass the full path to the file."
        ) from None
    except ImportError:
        raise ImportError(
            f"Opening {label} files needs the '{needs}' package, which isn't installed yet.\n"
            f"   Install it once:   pip install {needs}\n"
            "   then run this again."
        ) from None

    if isinstance(result, dict):
        sheets = ", ".join(repr(k) for k in list(result)[:5])
        raise ValueError(
            f"That file has several tables/sheets ({sheets} ...). Load one by name, e.g.\n"
            f"   df = kami.load('{os.path.basename(path)}', sheet_name='Sheet1')"
        )

    _emit(
        f"Kami: Loaded {len(result):,} rows x {result.shape[1]} columns from "
        f"'{os.path.basename(path)}' ({label})."
    )
    _emit("      Next step: explore it with   kami.look(df)")
    _emit("      Messy data (quoted numbers, text dates)?  Tidy it with   df = kami.clean(df)")
    return _use_df(result)


def info() -> None:
    """Print a getting-started guide, organised by what you're trying to do."""
    line = "=" * 64
    rule = "-" * 64
    _emit(line)
    _emit("  KAMI  -  a friendly machine-learning helper for beginners")
    _emit(line)
    _emit("")
    _emit("NEW HERE?  kami.roadmap() walks you through the whole journey.")
    _emit("The short version:")
    _emit("")
    _emit("   df = kami.load('yourfile.csv')        # 1. get your data in")
    _emit("   kami.look(df)                         # 2. explore it")
    _emit("   result = kami.train(df, target='X')   # 3. build a model")
    _emit("   kami.predict(result, new_row)         # 4. predict on new data")
    _emit("")
    _emit("   ('target' just means the column you want to predict.)")
    _emit("")
    _emit(rule)
    _emit("EXPLORE YOUR DATA  -  get to know what you've got")
    _emit("   kami.load('file.csv')      open a file (csv, excel, json, parquet, ...)")
    _emit("   kami.clean(df)             tidy common messes (text-numbers, dates, dupes)")
    _emit("   kami.look(df)              column-by-column detail and advice")
    _emit("   kami.report(df)            a quick high-level summary")
    _emit("   kami.audit(df)             find problems that hurt models (missing, leakage)")
    _emit("   kami.recommend(df)         suggest which column to predict (your target)")
    _emit("")
    _emit(rule)
    _emit("BUILD A MODEL  -  learn the pattern in your data")
    _emit("   result = kami.train(df, target='X')     build a model (safe default picked)")
    _emit("   champ  = kami.bakeoff(df, target='X')   auto-try many models, keep the best")
    _emit("   kami.models()                           list every model you can choose")
    _emit("   (the options for kami.train are listed further down.)")
    _emit("")
    _emit(rule)
    _emit("UNDERSTAND YOUR MODEL  -  see what it learned, and how good it is")
    _emit("   kami.plot_predictions(result)         how close its predictions land")
    _emit("   kami.feature_importance(result)       which columns it leaned on most")
    _emit("   kami.whatif(result, row, feature='X') how the answer shifts as 'X' changes")
    _emit("   result.summary()                      recap how it was built")
    _emit("")
    _emit(rule)
    _emit("PREDICT & USE IT  -  put the model to work")
    _emit("   new_row = {'col1': value, 'col2': value}   describe one new case")
    _emit("   kami.predict(result, new_row)              predict, with a plain explanation")
    _emit("   result.example_row()                       a ready-to-edit template row")
    _emit("")
    _emit(rule)
    _emit("MAKE CHARTS  -  each explains what you're seeing; add save='name.png' to save")
    _emit("   kami.suggest_charts(df)       not sure which chart? start here")
    _emit("   kami.quickplot(df)            a fast visual overview of everything")
    _emit("   kami.scatter(df, 'a', 'b')    do two numbers move together?")
    _emit("   kami.hist(df, 'a')            how one column's values are spread")
    _emit("   kami.bar(df, 'a')             rows per category (add y='b' for averages)")
    _emit("   kami.line(df, 'a', 'b')       a trend, e.g. over dates")
    _emit("   kami.pie(df, 'a')             each category's share of the whole")
    _emit("   kami.heatmap(df)              how all the numeric columns correlate")
    _emit("   kami.compare({'A': r1, 'B': r2})   several models side by side")
    _emit("")
    _emit(rule)
    _emit("LEARN THE CONCEPTS  -  plain-English help, no jargon")
    _emit("   kami.roadmap()                the whole Kami journey, in order")
    _emit("   kami.learn()                  short lessons on each model")
    _emit("   kami.translate('overfitting') what a data-science word actually means")
    _emit("   kami.models('regression')     the model menu for one kind of problem")
    _emit("")
    _emit(rule)
    _emit("OPTIONS for kami.train(...)")
    _emit("   missing=   blanks: 'fill' (default), 'drop' the rows, or 'ask' to choose")
    _emit("   explain=   chatter: 'guide' (default), 'silent', or 'teacher' (adds the why)")
    _emit("   task=      'regression' / 'classification' to override the auto-detect")
    _emit("   model=     a model name (see kami.models()); default is a strong all-rounder")
    _emit("   ignore=    ['col1', 'col2'] to leave columns out of the model")
    _emit("   validate=  'cross' for 5-fold cross-validation instead of one split")
    _emit("")
    _emit(rule)
    _emit("FILES kami.load() can open  (it picks the reader from the file name):")
    _emit("   .csv .tsv .txt  (always)      .xlsx .xls  (openpyxl)      .json")
    _emit("   .parquet .feather .orc  (pyarrow)    .h5 .hdf5  (tables)    .xml  (lxml)")
    _emit("   If a package is missing, Kami tells you exactly which to install.")
    _emit("")
    _emit(rule)
    _emit("LITTLE EXTRAS:")
    _emit("   kami.set_typing(True)    Kami 'types' its output  (False to stop)")
    _emit("   result.raw               the raw scikit-learn model, for full control")
    _emit(line)


def _plain_dtype(s: pd.Series) -> str:
    """Describe a column's type in plain English."""
    if pd.api.types.is_bool_dtype(s):
        return "true/false"
    if pd.api.types.is_datetime64_any_dtype(s):
        return "dates"
    if pd.api.types.is_integer_dtype(s):
        return "whole numbers"
    if pd.api.types.is_float_dtype(s):
        non_null = s.dropna()
        if len(non_null) and (non_null == non_null.round()).all():
            return "whole numbers"  # whole numbers pandas just stored with a decimal point
        return "decimal numbers"
    if isinstance(s.dtype, pd.CategoricalDtype):
        return "categories"
    return "text"


def _example_values(s: pd.Series, k: int = 3) -> str:
    """A short, readable sample of the real values in a column."""
    seen = s.dropna().unique()[:k]
    if pd.api.types.is_datetime64_any_dtype(s):
        out = []
        for v in seen:
            ts = pd.Timestamp(v)
            out.append(ts.strftime("%Y-%m-%d") if ts == ts.normalize()
                       else ts.strftime("%Y-%m-%d %H:%M"))
        return ", ".join(out) if out else "(all blank)"
    out = []
    for v in seen:
        if isinstance(v, (float, np.floating)):
            fv = float(v)
            out.append(str(int(fv)) if fv.is_integer() else f"{fv:.4g}")
        elif isinstance(v, (int, np.integer)):
            out.append(str(int(v)))
        else:
            text = str(v)
            out.append(text[:20] + "..." if len(text) > 20 else text)
    return ", ".join(out) if out else "(all blank)"


def _column_profile(data: pd.DataFrame, max_cols: int = 50) -> list[str]:
    """Build a plain-English, one-line-per-column profile for look()."""
    cols = list(data.columns)
    width = min(max((len(str(c)) for c in cols), default=0), 24)
    n_rows = len(data)
    lines = []
    for col in cols[:max_cols]:
        s = data[col]
        kind = _plain_dtype(s)
        examples = _example_values(s)
        distinct = s.nunique(dropna=True)
        missing = int(s.isna().sum())
        notes = [f"{distinct:,} distinct"]
        if missing:
            notes.append(f"{missing:,} missing")
        if distinct == n_rows and n_rows > 0 and not pd.api.types.is_float_dtype(s):
            notes.append("looks like an ID")
        prefix = f"      {str(col):<{width}}  {kind}, e.g. "
        suffix = f"   ({', '.join(notes)})"
        avail = max(10, 96 - len(prefix) - len(suffix))
        if len(examples) > avail:
            examples = examples[:avail - 3].rstrip(", ") + "..."
        lines.append(prefix + examples + suffix)
    if len(cols) > max_cols:
        lines.append(f"      ...and {len(cols) - max_cols} more column(s).")
    return lines


def _show_head(data: pd.DataFrame, n: int = 5) -> None:
    """Show the first n rows: a rich table inside Jupyter, a plain text table elsewhere."""
    head = data.head(n)
    if _in_jupyter():
        try:
            from IPython.display import display
            display(head)
            return
        except Exception:
            pass
    print(head.to_string())


def _show_scorecard(pairs, explain: str, title: str = "TRAINING RESULTS") -> None:
    """Show the key results as their own highlighted block, so they don't get lost.

    `pairs` is a list of (label, value) tuples. In Jupyter this renders as a small
    table; elsewhere it prints inside an ASCII box.
    """
    if _LEVELS.get(explain, 1) < 1:
        return
    if _in_jupyter():
        try:
            from IPython.display import display
            table = pd.DataFrame(
                [v for _, v in pairs],
                index=[k for k, _ in pairs],
                columns=[title.title()],
            )
            display(table)
            return
        except Exception:
            pass
    label_w = max(len(str(k)) for k, _ in pairs)
    rows = [f"{str(k).ljust(label_w)}   {v}" for k, v in pairs]
    inner = max([len(title)] + [len(r) for r in rows])
    bar = "   +" + "-" * (inner + 2) + "+"
    out = [bar, f"   | {title.ljust(inner)} |", bar]
    out += [f"   | {r.ljust(inner)} |" for r in rows]
    out.append(bar)
    _emit("\n".join(out))


def _fmt_num(v) -> str:
    """Format a number nicely for display (commas, no needless decimals)."""
    try:
        f = float(v)
    except (TypeError, ValueError):
        return str(v)
    if f == int(f) and abs(f) < 1e15:
        return f"{int(f):,}"
    return f"{f:,.2f}"


def _show_prediction(result, preds, proba=None) -> None:
    """Show the model's prediction as its own highlighted block, with a plain explanation.

    The prediction is the whole point of predicting, so it gets the same standout
    treatment as the training scorecard instead of being a bare value in the output.
    """
    target = result.target
    n = len(preds)

    if n == 1:
        val = preds[0]
        if result.task == "regression":
            _show_scorecard([(f"Predicted {target}", _fmt_num(val))], "guide", title="PREDICTION")
            _emit(_wrap(
                f"Kami: For the row you gave, the model predicts {target} of about "
                f"{_fmt_num(val)}."
            ))
        else:
            pairs = [(f"Predicted {target}", str(val))]
            if proba is not None:
                pairs.append(("Confidence", f"{float(proba[0]):.0%}"))
            _show_scorecard(pairs, "guide", title="PREDICTION")
            conf = (
                f" The model is about {float(proba[0]):.0%} confident in that."
                if proba is not None else ""
            )
            _emit(_wrap(f"Kami: For the row you gave, the model predicts {target} = {val}.{conf}"))
        return

    # several rows -> show them as a table
    table = pd.DataFrame({f"predicted_{target}": preds})
    if proba is not None:
        table["confidence"] = [f"{float(p):.0%}" for p in proba]
    _emit(f"Kami: Predicted {target} for {n:,} rows:")
    shown = table.head(20)
    if _in_jupyter():
        try:
            from IPython.display import display
            display(shown)
            if n > 20:
                _emit(f"   (showing the first 20 of {n:,}; all of them are in your variable)")
            return
        except Exception:
            pass
    _emit(shown.to_string())
    if n > 20:
        _emit(f"   (showing the first 20 of {n:,}; all of them are in your variable)")


def models(task: str = None) -> None:
    """List the models Kami can train, grouped by family, with a one-line note on each.

    Examples:
        kami.models()                  # both regression and classification
        kami.models('regression')      # just the number-predicting models
        kami.models('classification')  # just the category-sorting models

    The one marked [recommended] is a safe, strong place to start.
    """
    if task is None:
        tasks = ["regression", "classification"]
    else:
        t = str(task).strip().lower()
        if t.startswith("reg"):
            tasks = ["regression"]
        elif t.startswith("class") or t.startswith("clf") or t.startswith("cat"):
            tasks = ["classification"]
        else:
            raise ValueError(
                "task must be 'regression' (predicting a number) or 'classification' "
                "(sorting into categories) - or leave it out to see both."
            )
    for t in tasks:
        head = ("REGRESSION models  (for predicting a number)" if t == "regression"
                else "CLASSIFICATION models  (for sorting rows into categories)")
        _emit(head)
        rec = _RECOMMENDED[t]
        for group, names in _MODEL_GROUPS[t].items():
            _emit(f"   {group}:")
            for name in names:
                fam = _LESSON_ALIASES.get(name.lower())
                teaser = _LESSONS[fam]["teaser"] if fam else ""
                mark = "   [recommended]" if name == rec else ""
                _emit(f"     - {name}  ->  {teaser}{mark}")
        _emit("")
    _emit("Use any of them:   result = kami.train(df, target='...', model='NAME')")
    _emit("Curious how one works?   kami.learn('RandomForest')")
    return None


def roadmap() -> None:
    """Show the typical Kami journey, in order - a map from a raw file to predictions.

    Example:  kami.roadmap()
    """
    _emit("THE KAMI ROADMAP - a typical path from raw data to predictions:")
    _emit("")
    steps = [
        ("1. LOAD", "df = kami.load('yourfile.csv')",
         "read your file into a table"),
        ("2. CLEAN  (optional)", "df = kami.clean(df)",
         "fix text-that-should-be-numbers, text dates, stray spaces"),
        ("3. EXPLORE", "kami.look(df)    or    kami.report(df)",
         "see your columns and what they hold"),
        ("4. CHECK", "kami.audit(df)",
         "catch problems that quietly hurt models (missing, leakage, IDs)"),
        ("5. PICK A TARGET", "kami.recommend(df)",
         "decide which column you want to predict"),
        ("6. TRAIN", "result = kami.train(df, target='...')",
         "build a model - Kami picks a safe default for you"),
        ("7. EVALUATE", "kami.plot_predictions(result)",
         "see how good it is, and kami.feature_importance(result) for what mattered"),
        ("8. PREDICT", "kami.predict(result, new_row)",
         "use the model on brand-new data"),
        ("9. COMPARE", "champ = kami.bakeoff(df, target='...')",
         "auto-try many models and keep the best one"),
    ]
    for label, code, why in steps:
        _emit(f"   {label}")
        _emit(f"       {code}")
        _emit(_wrap(f"({why})", first="       ", indent="        "))
        _emit("")
    _emit("You can stop anywhere - even just LOAD + LOOK is useful on its own.")
    _emit("Stuck on a word?  kami.translate('overfitting')      "
          "New to the models?  kami.learn()")
    return None


def look(data=_OMITTED, target: str | None = None,
         task: str | None = None) -> None:
    """Understand a dataset and get advice - without training anything yet.

    If you've already loaded or looked at a dataset, you can skip re-passing it:
    kami.look(target='price') reuses the last dataframe Kami saw.
    """
    data = _resolve_df(data, "kami.look")
    _require_dataframe(data, "kami.look")
    _emit("Kami is looking at your data...")
    _emit("")
    _emit(f"   Shape:   {len(data):,} rows  x  {data.shape[1]} columns")
    _emit("")
    _emit("   A peek at the first 5 rows:")
    _show_head(data, 5)

    _emit("")
    _emit("   What each column holds:")
    for line in _column_profile(data):
        _emit(line)

    missing_per_col = data.isna().sum()
    cols_with_missing = missing_per_col[missing_per_col > 0]
    has_missing = len(cols_with_missing) > 0
    if not has_missing:
        _emit("\n   Missing values: none")
    else:
        total = int(cols_with_missing.sum())
        names = ", ".join(str(c) for c in cols_with_missing.index)
        _emit("")
        _emit(_wrap(f"Missing values: {total:,} total (in: {names}).",
                    first="   ", indent="   "))
        _emit(_wrap(
            'kami.train() fills these for you by default (numbers -> median, text -> "Missing"); '
            "you can drop those rows instead - see the example below once you set a target.",
            first="   ", indent="   ",
        ))

    if target is None:
        example_col = str(data.columns[-1])
        _emit("")
        _emit(_wrap(
            "This is your data-exploration step: look over the columns, their types, and "
            "any missing values above to get a feel for what you've got. When you're ready "
            "to model, tell Kami which column you want to predict.",
            first="   ", indent="   ",
        ))
        _emit("")
        _emit("   Next steps:")
        _emit("     1.  Decide which column you want to predict (this is your 'target').")
        _emit("         Not sure? Run kami.recommend(df) for suggestions.")
        _emit("     2.  Run look again WITH that target, and Kami will recommend a model:")
        _emit(f"             kami.look(df, target='{example_col}')      # <- use your own column name")
        _emit("     3.  Then train it (Kami guides you and picks a safe default model):")
        _emit(f"             result = kami.train(df, target='{example_col}')")
        _emit("")
        _emit(_wrap(
            "Tip: to check your data for common problems first (missing values, duplicates, "
            "leakage, columns that won't help), run kami.audit(df) or "
            f"kami.audit(df, target='{example_col}').",
            first="   ", indent="   ",
        ))
        return

    if target not in data.columns:
        raise ValueError(f"There is no column called '{target}'. "
                         f"Your columns are: {list(data.columns)}")

    y = data[target]
    detected = _detect_task(y, task)
    if detected == "regression":
        plain = "predicting a number on a sliding scale, not sorting rows into groups"
    else:
        plain = "sorting rows into groups/categories, not predicting a number"
    _emit(f"\n   Target: '{target}'  ->  looks like a {detected.upper()} problem")
    _emit(f"      ({plain})")
    if (not pd.api.types.is_numeric_dtype(y) and not pd.api.types.is_bool_dtype(y)
            and not pd.api.types.is_datetime64_any_dtype(y)
            and _numeric_text_fraction(y) >= _NUMERIC_TEXT_MIN):
        _emit("      Note: these are numbers stored as text - kami.train reads them as "
              "numbers,")
        _emit("      and df = kami.clean(df) converts them up front.")

    X = data.drop(columns=[target])
    id_cols = _find_id_like(X)
    text_cols = [c for c in _text_columns(X) if c not in id_cols]
    if id_cols:
        _emit("")
        _emit(_wrap(f"ID-style column(s) Kami would drop (they label rows, they don't "
                    f"help predict): {id_cols}", first="   ", indent="   "))
    if text_cols:
        _emit(_wrap(f"Text column(s) Kami would turn into numbers so the model can use "
                    f"them: {text_cols}", first="   ", indent="   "))
    if detected == "classification":
        _check_imbalance(y.dropna(), "guide")

    # one clear recommendation, with the full catalogue one call away
    recommended = _RECOMMENDED[detected]
    _emit("")
    _emit(_wrap(
        f"Recommended model: {recommended} - a strong, forgiving default that works "
        f"well without any tuning.",
        first="   ", indent="   "))
    _emit(f"   See all {sum(len(v) for v in _MODEL_GROUPS[detected].values())} "
          f"models you could use instead:  kami.models('{detected}')")

    _emit("\n   Next step - train a model. Easiest is to let Kami pick the recommended one:")
    _emit(f"      result = kami.train(df, target='{target}')")
    _emit("\n   ...or choose a specific model by name:")
    _emit(f"      result = kami.train(df, target='{target}', model='{recommended}')")
    if has_missing:
        _emit("")
        _emit(_wrap(
            "Your data has blanks. Kami fills them automatically - but if you'd rather "
            "drop the rows with blanks instead, use this line:",
            first="   ", indent="   ",
        ))
        _emit(f"      result = kami.train(df, target='{target}', missing='drop')")


_VERDICT_STARS = {
    "Excellent": 5, "Strong": 4, "Good": 4, "Moderate": 3,
    "Weak": 2, "Very weak": 1, "No signal": 1,
}


def _verdict_stars(verdict: str) -> str:
    """A text star-rating for a verdict, e.g. 'Good' -> '[****-]'."""
    filled = _VERDICT_STARS.get(verdict, 3)
    return "[" + "*" * filled + "-" * (5 - filled) + "]"


def _confidence(n_rows, n_test, spread, missing_frac, imbalance, task):
    """Rate how much to trust the evaluation: 'High'/'Moderate'/'Low' + a plain reason."""
    pts = 0
    worries = []
    if n_rows >= 1000:
        pts += 2
    elif n_rows >= 300:
        pts += 1
    else:
        worries.append(f"only {n_rows} rows of data")
    if spread is not None:                       # cross-validation was used
        pts += 1                                  # sturdier than a single split
        if spread <= 0.07:
            pts += 1
        elif spread > 0.15:
            worries.append("the scores jump around between folds")
    elif n_test is not None:                     # a single holdout split
        if n_test >= 200:
            pts += 2
        elif n_test >= 50:
            pts += 1
        else:
            worries.append(f"only {n_test} rows to test on")
    if missing_frac and missing_frac > 0.3:
        pts -= 1
        worries.append("a large share of values were missing")
    if imbalance is not None and imbalance >= 0.9:
        pts -= 1
        worries.append("the categories are very lopsided")
    level = "High" if pts >= 3 else ("Moderate" if pts >= 1 else "Low")
    if worries:
        reason = "; ".join(worries)
    else:
        good = []
        if n_rows >= 1000:
            good.append("plenty of rows")
        if spread is not None:
            good.append("it held up across several folds")
        elif n_test:
            good.append(f"{n_test} unseen test rows")
        reason = ", ".join(good) if good else "the basic checks look fine"
    return level, reason


def _conc_line(label: str, text: str, width: int = 58) -> None:
    """Print one 'Label:  wrapped text' line inside the conclusion box."""
    head = f"   {label:<13} "
    indent = " " * len(head)
    lines = textwrap.wrap(text, width=width) or [""]
    _emit(head + lines[0])
    for ln in lines[1:]:
        _emit(indent + ln)


def _rmse_example(eval_actual, eval_pred):
    """Pick one typical predicted-vs-actual pair to make RMSE concrete."""
    try:
        a = np.asarray(eval_actual, dtype=float)
        p = np.asarray(eval_pred, dtype=float)
        if len(a) == 0:
            return None
        err = np.abs(a - p)
        i = int(np.argsort(err)[len(err) // 2])   # a middling (typical) miss
        return f"predicted {int(round(p[i]))}, actual {int(round(a[i]))}"
    except Exception:
        return None


def _top_drivers(model, feat_names, k=3):
    """Top-k most influential feature names, if the model exposes that; else []."""
    vals = None
    if hasattr(model, "feature_importances_"):
        vals = np.asarray(model.feature_importances_, dtype=float)
    elif hasattr(model, "coef_"):
        coef = np.asarray(model.coef_, dtype=float)
        vals = np.abs(coef).mean(axis=0) if coef.ndim > 1 else np.abs(coef)
    if vals is None or len(vals) == 0:
        return []
    order = np.argsort(vals)[::-1]
    return [str(feat_names[i]) for i in order[:k] if vals[i] > 0]


def _learned_insights(task, target, score, baseline, model, feat_names):
    """A few plain-English takeaways generated from the actual model results."""
    out = []
    if task == "regression":
        has_signal = score > 0.2
    else:
        has_signal = baseline is not None and score > baseline + 0.05

    if has_signal:
        drivers = _top_drivers(model, feat_names, k=2)
        if drivers:
            if len(drivers) == 1:
                out.append(f"'{drivers[0]}' looks most related to '{target}'.")
            else:
                pretty = " and ".join(f"'{d}'" for d in drivers)
                out.append(f"{pretty} look most related to '{target}' "
                           f"(related to, not necessarily the cause).")
        if task == "regression":
            out.append(f"There's real signal here - your columns do help predict '{target}'.")
        else:
            out.append("It beats simply guessing the most common answer, so it found a "
                       "real pattern.")
    else:
        if task == "regression":
            out.append(f"Your columns don't explain '{target}' well - the useful "
                       f"information may not be in this data.")
        else:
            out.append("It doesn't clearly beat just guessing the most common answer yet - "
                       "the useful signal may not be in these columns.")
    return out


def _print_conclusion(task, target, score, rmse, baseline, model, feat_names,
                      eval_actual, eval_pred, conf_level, conf_reason, explain):
    """The plain-English takeaway: what we found, what it means, and what to do next."""
    verdict, _meaning = _interpret_score(task, score, baseline)
    stars = _verdict_stars(verdict)
    bar = "=" * 70

    if task == "regression":
        pct = max(0.0, score) * 100
        if score >= 0.75:
            answer = "Yes - and quite well."
        elif score >= 0.5:
            answer = "Yes - reasonably well."
        elif score >= 0.25:
            answer = "Somewhat."
        elif score > 0.05:
            answer = "Only a little."
        else:
            answer = "Not really - it does no better than guessing the average."
        howgood = (f"R2 = {score:.2f}. In plain terms, the model explains about "
                   f"{pct:.0f}% of why '{target}' changes from one row to the next.")
    else:
        if baseline is not None and score <= baseline + 1e-9:
            answer = (f"Not really - no better than always guessing the most common "
                      f"answer ({baseline:.0%}).")
        elif baseline is not None and score >= baseline + 0.2:
            answer = "Yes - clearly better than guessing."
        else:
            answer = "Somewhat - a little better than guessing."
        howgood = (f"Accuracy = {score:.0%}. On data it had never seen, the model got "
                   f"it right about {score * 100:.0f} times in 100.")

    _emit("")
    _emit("   " + bar)
    _emit("   WHAT KAMI FOUND")
    _emit("   " + bar)
    _conc_line("Question", f"Can '{target}' be predicted from the other columns?")
    _conc_line("Answer", answer)
    _emit("")
    _conc_line("How good", howgood)
    if task == "regression" and rmse is not None:
        ex = _rmse_example(eval_actual, eval_pred)
        tail = f" (e.g. {ex})" if ex else ""
        _conc_line("Typical miss", f"the model is usually off by about {rmse:.0f}{tail}.")
    _conc_line("Verdict", f"{stars} {verdict}")
    _conc_line("Confidence", f"{conf_level} - {conf_reason}.")

    insights = _learned_insights(task, target, score, baseline, model, feat_names)
    if insights:
        _emit("")
        _emit("   What we learned:")
        for line in insights:
            _emit(_wrap(f"- {line}", first="     ", indent="       "))

    nxt = ("kami.feature_importance(result)"
           if _top_drivers(model, feat_names, 1) else "kami.plot_predictions(result)")
    _emit("")
    _conc_line("Do next", nxt)
    _emit("   " + bar)


def _crossval_and_report(model, X, y, task, target, explain, random_state):
    """Run k-fold cross-validation, narrate it, fit a final model on all data.

    Returns (score, rmse, eval_actual, eval_pred) so train() can build the result.
    The score is the average across folds; eval_* are out-of-fold predictions, so
    plot_predictions shows every row predicted by a model that didn't see it.
    """
    n = len(X)
    if task == "classification":
        folds = max(2, min(5, int(y.value_counts().min())))
        splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=random_state)
    else:
        folds = max(2, min(5, n))
        splitter = KFold(n_splits=folds, shuffle=True, random_state=random_state)

    _say(
        _wrap(
            f"Kami: Using {folds}-fold cross-validation - I split your data into {folds} "
            f"equal parts, then train {folds} times, each time testing on a different part "
            f"and training on the other {folds - 1}. Every row gets tested exactly once."
        ),
        explain, 1,
    )
    _say(
        _wrap(
            "This is more thorough than a single split: you see how stable the score is "
            "across different test sets, not just one that might be lucky or unlucky.",
            first="    ", indent="    ",
        ),
        explain, 2,
    )

    scores = cross_val_score(model, X, y, cv=splitter)
    cv_preds = cross_val_predict(model, X, y, cv=splitter)
    model.fit(X, y)  # final model trained on ALL the data, ready to predict with

    score = float(scores.mean())
    spread = float(scores.std())
    rmse = None
    data_line = f"{n:,} rows, {folds}-fold cross-validation"

    if task == "classification":
        baseline = float(y.value_counts(normalize=True).max())
        verdict, meaning = _interpret_score("classification", score, baseline)
        _show_scorecard(
            [
                ("Model", _model_label(model)),
                ("Problem", "classification (sorting rows into groups)"),
                (f"Average accuracy ({folds} folds)", f"{score:.1%}"),
                ("Spread across folds (+/-)", f"{spread:.1%}"),
                ("Baseline (guess most common)", f"{baseline:.1%}"),
                ("Verdict", verdict),
                ("Data", data_line),
            ],
            explain,
        )
        fold_rows = [(i, float(s), f"{s:.1%}") for i, s in enumerate(scores, 1)]
    else:
        baseline = None
        rmse = float(np.sqrt(mean_squared_error(y, cv_preds)))
        verdict, meaning = _interpret_score("regression", score)
        _show_scorecard(
            [
                ("Model", _model_label(model)),
                ("Problem", "regression (predicting a number)"),
                (f"Average R2 ({folds} folds)", f"{score:.3f}"),
                ("Spread across folds (+/-)", f"{spread:.3f}"),
                ("RMSE (typical error size)", f"{rmse:.3f}"),
                ("Verdict", verdict),
                ("Data", data_line),
            ],
            explain,
        )
        fold_rows = [(i, float(s), f"{s:.3f}") for i, s in enumerate(scores, 1)]

    # a little ASCII bar chart of the fold scores, so the spread is visible at a glance
    _emit("   SCORE ON EACH FOLD")
    width = 24
    for i, val, label in fold_rows:
        filled = int(round(max(0.0, min(1.0, val)) * width))
        bar = "#" * filled + "-" * (width - filled)
        _emit(f"     Fold {i}  |{bar}|  {label}")
    if spread <= 0.03:
        steady = ("The folds are very close together, so the score is stable and "
                  "trustworthy.")
    elif spread <= 0.08:
        steady = "The folds are fairly close, so the score is reasonably stable."
    else:
        steady = ("The folds vary quite a bit, so treat the average as rough - the score "
                  "depends a lot on which rows are tested.")
    _say(_wrap("   " + steady, first="   ", indent="   "), explain, 1)
    return score, rmse, np.asarray(y), np.asarray(cv_preds), folds, spread


def train(
    data=_OMITTED,
    target: str = None,
    model=None,
    task: str | None = None,
    explain: str = "guide",
    missing: str = "fill",
    test_size: float = 0.2,
    random_state: int = 42,
    validate: str = "holdout",
    ignore=None,
):
    """Train a model, with the classic beginner mistakes handled and explained for you.

    validate='holdout' (default) checks the model on one held-back test set.
    validate='cross' uses 5-fold cross-validation - more thorough, and it shows you
    how much the score wobbles depending on which rows land in the test set.
    ignore=['col1', 'col2'] leaves those columns out of the model entirely.
    """
    explain = _check_explain(explain)

    data = _resolve_df(data, "kami.train")
    _require_dataframe(data, "kami.train")

    validate = str(validate).lower()
    if validate not in ("holdout", "cross"):
        raise ValueError(
            "validate must be 'holdout' (one test split, the default) or "
            "'cross' (5-fold cross-validation)."
        )

    if target is None:
        raise ValueError(
            "kami.train also needs the name of the column you want to predict.\n\n"
            "   Run it like this (save the result so you can use it after):\n\n"
            "       result = kami.train(df, target='the_column_you_want_to_predict')\n\n"
            "   Not sure which column to predict, or what your columns are called?\n"
            "   Explore first with:  kami.look(df)"
        )

    if target not in data.columns:
        raise ValueError(
            f"There is no column called '{target}'. "
            f"Your columns are: {list(data.columns)}"
        )

    _say("Kami is getting your data ready...", explain, 1)

    # ---- STEP 1: understand what we're working with ----
    _step(1, "Understanding your data", explain)
    y = data[target]
    if (not pd.api.types.is_numeric_dtype(y) and not pd.api.types.is_bool_dtype(y)
            and not pd.api.types.is_datetime64_any_dtype(y)
            and _numeric_text_fraction(y) >= _NUMERIC_TEXT_MIN):
        example = next((v for v in y.dropna().astype(str) if v.strip()), "")
        y = _coerce_numeric_text(y)
        data = data.copy()
        data[target] = y
        _say(_wrap(f"Kami: Reading '{target}' as a number - it was stored as text "
                   f"(like '{example}'). Tip: df = kami.clean(df) does this up front.",
                   first="", indent="      "), explain, 1)
    # a blank (or infinite) target can't be learned from - drop those rows
    if pd.api.types.is_numeric_dtype(y):
        y = y.replace([np.inf, -np.inf], np.nan)
    if y.isna().any():
        keep = y.notna()
        dropped = int((~keep).sum())
        data = data.loc[keep]
        y = y.loc[keep]
        if len(data) == 0:
            raise ValueError(
                f"Every row has a blank '{target}', so there's nothing to learn from. "
                f"Is '{target}' the right column to predict?"
            )
        _say(_wrap(f"Kami: Dropped {dropped:,} row(s) with a blank '{target}' - a model "
                   f"can't learn from rows whose answer is missing.",
                   first="", indent="      "), explain, 1)
    X = data.drop(columns=[target])
    X = X.replace([np.inf, -np.inf], np.nan)        # infinities -> blanks (then filled)
    if ignore:
        ignore = [ignore] if isinstance(ignore, str) else list(ignore)
        drop_now = [c for c in ignore if c in X.columns]
        missing_names = [c for c in ignore if c not in X.columns and c != target]
        if drop_now:
            X = X.drop(columns=drop_now)
            _say(_wrap(f"Kami: Ignoring column(s) {drop_now} as you asked - they won't be "
                       "used to train.", first="", indent="      "), explain, 1)
        if missing_names:
            _say(_wrap(f"Kami: (Couldn't ignore {missing_names} - no such column. "
                       "Carrying on.)", first="", indent="      "), explain, 1)
    detected = _detect_task(y, task)
    # if the user explicitly picked a model, trust what kind of model it is over the guess
    if task is None and isinstance(model, str):
        mt = _model_task(model)
        if mt is not None and mt != detected:
            goal = ("predicting a number" if mt == "regression"
                    else "sorting rows into categories")
            other = "GradientBoostingClassifier" if mt == "regression" else "GradientBoostingRegressor"
            _say(_wrap(
                f"Kami: '{model}' is a {mt} model, so I'll treat this as {goal} "
                f"(not the other way round). If you meant the opposite, pick a model like "
                f"'{other}', or pass task='classification'/'regression'.",
                first="", indent="      "), explain, 1)
            detected = mt
    # a regression target must be numeric; coerce text-numbers, else fail clearly
    if detected == "regression" and not pd.api.types.is_numeric_dtype(y):
        coerced = _coerce_numeric_text(y)
        if float(coerced.notna().mean()) >= 0.5:
            y = coerced.replace([np.inf, -np.inf], np.nan)
            keep = y.notna()
            if not keep.all():
                X, y = X.loc[keep], y.loc[keep]
        else:
            raise ValueError(
                f"'{target}' isn't numeric, so it can't be predicted as a number. "
                f"Use a classification model, or pass task='classification'."
            )
    # for the confidence rating later:
    missing_frac = float(np.asarray(X.isna()).mean()) if X.shape[1] else 0.0
    imbalance = (float(y.value_counts(normalize=True).max())
                 if detected == "classification" else None)
    if detected == "regression":
        plain = "predicting a number, not sorting rows into categories"
    else:
        plain = "sorting rows into categories, not predicting a number"
    _say(
        f"Kami: This looks like a {detected.upper()} problem (target = '{target}').",
        explain,
        1,
    )
    _say(f"    ({plain})", explain, 2)

    # ---- STEP 2: clean and prepare the data ----
    _step(2, "Cleaning and prepping your data", explain)
    id_cols = _find_id_like(X)
    if id_cols and len(id_cols) == X.shape[1]:
        _say(
            "Kami: Every column looks ID-like, so I kept them all - "
            "otherwise there'd be nothing to learn from.",
            explain,
            1,
        )
        id_cols = []
    if id_cols:
        X = X.drop(columns=id_cols)
        _say(
            f"Kami: Dropped ID-style column(s) {id_cols} - they identify rows "
            f"but don't help predict.",
            explain,
            1,
        )
        _say(
            _wrap(
                "If you leave IDs in, the model can 'cheat' by memorising them "
                "instead of learning the real pattern.",
                first="    ", indent="    ",
            ),
            explain,
            2,
        )

    if y.isna().any():
        keep = ~y.isna()
        X, y = X[keep], y[keep]
        _say(
            "Kami: Removed rows with a missing target value "
            "(can't learn from an unknown answer).",
            explain,
            1,
        )

    # capture the columns the user actually types (the original ones, before we
    # engineer dates or one-hot encode), so predict()'s template matches their data
    input_columns = list(X.columns)
    input_example = {c: _example_scalar(X[c]) for c in input_columns}

    # turn any date columns into numbers the model can learn from
    X, date_cols = _engineer_dates(X, explain)

    X, y, fill_values = _apply_missing_policy(X, y, missing, explain, target)
    # learn the preprocessing now, so predict() can reuse it exactly (no drift):
    #   - impute: what to fill a blank with, per column (the TRAINING median/category)
    #   - text_cols: which columns get one-hot encoded
    impute = {}
    for col in X.columns:
        s = X[col]
        if pd.api.types.is_bool_dtype(s):
            present = s.dropna()
            impute[col] = bool(present.mode().iloc[0]) if len(present) else False
        elif pd.api.types.is_numeric_dtype(s):
            present = s.dropna()
            impute[col] = float(present.median()) if len(present) else 0.0
        else:
            impute[col] = "Missing"
    prep_text_cols = _text_columns(X)
    X, cat_keep = _encode_categoricals(X, explain)

    _check_size(len(X), explain)
    if detected == "classification":
        _check_imbalance(y, explain)

    # ---- STEP 3: choose a model ----
    _step(3, "Choosing a model", explain)
    auto_selected = model is None
    n_text = len(prep_text_cols)
    model = _resolve_model(
        model, detected, random_state, explain, target, missing,
        n_rows=len(X), n_text=n_text, n_numeric=X.shape[1] - n_text,
    )

    # ---- STEP 4: train, then score on data the model has never seen ----
    _step(4, "Training and scoring", explain)

    use_cross = validate == "cross"
    if use_cross and detected == "classification" and int(y.value_counts().min()) < 2:
        _say(
            _wrap(
                "Kami: At least one category has only a single example, which is too few to "
                "cross-validate - falling back to a single train/test split."
            ),
            explain, 1,
        )
        use_cross = False

    if use_cross:
        score, rmse, eval_actual, eval_pred, n_folds, spread = _crossval_and_report(
            model, X, y, detected, target, explain, random_state
        )
        n_test = None
        baseline = (
            float(y.value_counts(normalize=True).max())
            if detected == "classification" else None
        )
    else:
        n_folds = None
        spread = None
        stratify = None
        if detected == "classification" and y.nunique() > 1:
            vc = y.value_counts()
            # only stratify when every class has >=2 members (else the split errors)
            if int(vc.min()) >= 2:
                stratify = y
        baseline = None
        X_train, X_test, y_train, y_test = train_test_split(
            X, y, test_size=test_size, random_state=random_state, stratify=stratify
        )
        _say(
            f"Kami: Held back {test_size:.0%} of your data for testing, so the "
            f"score below is honest.",
            explain,
            1,
        )
        _say(
            _wrap(
                "The model studies only the training part, then I grade it on the "
                "testing part it has never seen. Scoring a model on the same data it "
                "trained on is the #1 beginner mistake - it gives a fake-high score.",
                first="    ", indent="    ",
            ),
            explain,
            2,
        )

        model.fit(X_train, y_train)
        preds = model.predict(X_test)
        n_train, n_test = len(X_train), len(X_test)
        data_line = f"{len(X):,} rows ({n_train:,} train / {n_test:,} test)"
        if n_test < 25:
            _say(
                _wrap(
                    f"Kami: HEADS UP - only {n_test} rows are in the test set, so the score "
                    f"below can swing a lot from run to run. Treat it as rough; for a steadier "
                    f"read use validate='cross' (or get more data if you can)."
                ),
                explain, 1,
            )
        rmse = None
        if detected == "classification":
            score = accuracy_score(y_test, preds)
            baseline = float(y_test.value_counts(normalize=True).max())
            verdict, meaning = _interpret_score("classification", score, baseline)
            _show_scorecard(
                [
                    ("Model", _model_label(model)),
                    ("Problem", "classification (sorting rows into groups)"),
                    ("Accuracy (unseen test data)", f"{score:.1%}"),
                    ("Baseline (guess most common)", f"{baseline:.1%}"),
                    ("Verdict", verdict),
                    ("Data", data_line),
                ],
                explain,
            )
        else:
            score = r2_score(y_test, preds)
            rmse = float(np.sqrt(mean_squared_error(y_test, preds)))
            verdict, meaning = _interpret_score("regression", score)
            _show_scorecard(
                [
                    ("Model", _model_label(model)),
                    ("Problem", "regression (predicting a number)"),
                    ("R2 score (unseen test data)", f"{score:.3f}"),
                    ("RMSE (typical error size)", f"{rmse:.3f}"),
                    ("Verdict", verdict),
                    ("Data", data_line),
                ],
                explain,
            )
        eval_actual = np.asarray(y_test)
        eval_pred = np.asarray(preds)

    # the plain-English takeaway: what we found, what it means, how sure to be
    conf_level, conf_reason = _confidence(
        len(X), n_test, spread, missing_frac, imbalance, detected
    )
    if explain != "silent":
        _print_conclusion(
            detected, target, score, rmse, baseline, model, list(X.columns),
            eval_actual, eval_pred, conf_level, conf_reason, explain,
        )

    _say("", explain, 1)
    _say(
        "Model built and ready to use. To predict on new data, "
        + _predict_example(input_columns, input_example)
        + "\n   (result.predict(new_row) works the same; result.raw is the scikit-learn model.)",
        explain,
        1,
    )
    _say(
        _wrap(
            "Heads-up: everything below uses 'result' - that's the variable you saved this "
            "model into. If you ran kami.train(...) without 'result =' in front, run it again "
            "with 'result =' so the lines below work.",
            first="   ", indent="   ",
        ),
        explain, 1,
    )
    alt_model = "Ridge" if detected == "regression" else "LogisticRegression"
    _say("", explain, 1)
    _say("   What you can try next:", explain, 1)
    _say("", explain, 1)
    _say("     UNDERSTAND IT", explain, 1)
    _say("       - How good is it?          kami.plot_predictions(result)", explain, 1)
    _say("       - Which columns mattered?  kami.feature_importance(result)", explain, 1)
    _say("       - Recap how it was built:  result.summary()", explain, 1)
    _say(f"       - How does it work?        kami.learn('{_model_label(model)}')", explain, 1)
    _say("", explain, 1)
    _say("     USE IT", explain, 1)
    _say("       - Predict on new data:     kami.predict(result, new_row)", explain, 1)
    _say(f"       - Try a what-if scenario:  kami.whatif(result, new_row, feature='{input_columns[0]}')",
         explain, 1)
    _say("", explain, 1)
    _say("     IMPROVE IT", explain, 1)
    _say(
        f"       - Try a different model:   result = kami.train(df, target='{target}', model='{alt_model}')",
        explain, 1,
    )
    _say(f"       - See all model options:   kami.models('{detected}')", explain, 1)
    _say(f"       - Auto-try many models:    champ = kami.bakeoff(df, target='{target}')", explain, 1)
    if validate != "cross":
        _say(
            f"       - Check it more rigorously: kami.train(df, target='{target}', validate='cross')",
            explain, 1,
        )

    return KamiResult(
        model=model,
        task=detected,
        model_name=_model_label(model),
        score=score,
        feature_columns=list(X.columns),
        target=target,
        id_cols=id_cols,
        rmse=rmse,
        input_columns=input_columns,
        input_example=input_example,
        fill_values=fill_values,
        eval_actual=eval_actual,
        eval_pred=eval_pred,
        impute=impute,
        text_cols=prep_text_cols,
        cat_keep=cat_keep,
        date_cols=date_cols,
        n_rows=len(X),
        missing=missing,
        validation=validate,
        n_folds=n_folds,
        random_state=random_state,
        test_size=test_size,
        baseline=baseline,
        confidence=conf_level,
        confidence_reason=conf_reason,
    )


def predict(result, new_data: pd.DataFrame, show: bool = True):
    """Make predictions with a model from kami.train(), staying in the kami.* family.

        result = kami.train(df, target="price")
        predictions = kami.predict(result, new_rows)   # same as result.predict(new_rows)

    `result.predict(new_rows)` does the identical thing - this is just here so the
    whole flow can read as kami.load -> kami.look -> kami.train -> kami.predict.
    """
    if not isinstance(result, KamiResult):
        raise ValueError(
            "kami.predict() expects the result from kami.train() as its first argument:\n"
            "   result = kami.train(df, target='...')\n"
            "   predictions = kami.predict(result, new_rows)"
        )
    return result.predict(new_data, show=show)


# ======================================================================
# CHARTS  -  simple, plain-language plots built on matplotlib.
#   kami.scatter / kami.bar / kami.line / kami.hist  -> explore your data
#   kami.plot_predictions(result)                    -> see how good a model is
#   kami.compare({...})                              -> compare several models
# Every chart shows inline in Jupyter; pass save='name.png' to also save a file.
# ======================================================================

def _get_plt():
    """Import matplotlib lazily, with a friendly message if it isn't installed."""
    try:
        import matplotlib.pyplot as plt
        return plt
    except ImportError:
        raise ImportError(
            "Kami's charts need matplotlib, which isn't installed yet.\n"
            "   Install it once with:   pip install matplotlib\n"
            "   (inside a notebook:     %pip install matplotlib   then restart the kernel)"
        ) from None


def _require_columns(data, cols) -> None:
    """Check that the named columns really exist, with a clear error if not."""
    _require_dataframe(data, "this chart")
    missing = [c for c in cols if c not in data.columns]
    if missing:
        raise ValueError(
            f"These column(s) aren't in your data: {missing}.\n"
            f"   Your columns are: {list(data.columns)}"
        )


def _finish_plot(fig, plt, save) -> None:
    """Save the figure if asked, then show it (inline in Jupyter) and free the memory."""
    if save:
        fig.savefig(save, bbox_inches="tight", dpi=120)
        _emit(f"Kami: Saved chart to '{save}'.")
    plt.show()
    plt.close(fig)


def _chart_note(what: str, yours: str = None) -> None:
    """Print a short 'how to read this chart' note, and an optional data-driven observation."""
    _emit(_wrap("What you're looking at: " + what, first="   ", indent="   "))
    if yours:
        _emit(_wrap("Your data: " + yours, first="   ", indent="   "))


def _corr_phrase(r: float):
    """Plain-English strength + direction for a correlation value."""
    a = abs(r)
    strength = "strong" if a >= 0.6 else ("moderate" if a >= 0.3 else "weak")
    direction = "positive" if r >= 0 else "negative"
    return strength, direction


def scatter(data: pd.DataFrame, x: str, y: str, save: str = None, notes: bool = True) -> None:
    """Scatter plot of two numeric columns - good for spotting a relationship.

    Example:  kami.scatter(df, x='height', y='weight')
    """
    _require_columns(data, [x, y])
    if notes:
        yours = None
        try:
            if pd.api.types.is_numeric_dtype(data[x]) and pd.api.types.is_numeric_dtype(data[y]):
                r = float(data[[x, y]].dropna().corr().iloc[0, 1])
                if pd.notna(r):
                    s, d = _corr_phrase(r)
                    yours = (f"'{x}' and '{y}' show a {s} {d} relationship "
                             f"(correlation {r:+.2f}).")
        except Exception:
            pass
        _chart_note(
            f"each dot is one row, placed by its '{x}' (left-right) and '{y}' (up-down). "
            f"A clear upward or downward drift means they move together; a shapeless cloud "
            f"means little relationship.",
            yours,
        )
    plt = _get_plt()
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.scatter(data[x], data[y], alpha=0.6, edgecolor="none")
    ax.set_xlabel(x)
    ax.set_ylabel(y)
    ax.set_title(f"{y} vs {x}")
    _finish_plot(fig, plt, save)


def line(data: pd.DataFrame, x: str, y: str, save: str = None, notes: bool = True) -> None:
    """Line chart of y against x (sorted by x) - good for trends over time.

    Example:  kami.line(df, x='date', y='sales')
    """
    _require_columns(data, [x, y])
    if notes:
        _chart_note(
            f"'{y}' plotted in order of '{x}' and joined up - good for seeing a trend, or how "
            f"'{y}' changes along '{x}' (often over time)."
        )
    plt = _get_plt()
    ordered = data[[x, y]].dropna().sort_values(x)
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.plot(ordered[x], ordered[y], marker="o", markersize=3, linewidth=1.5)
    ax.set_xlabel(x)
    ax.set_ylabel(y)
    ax.set_title(f"{y} over {x}")
    fig.autofmt_xdate()
    _finish_plot(fig, plt, save)


def hist(data: pd.DataFrame, column: str, bins: int = 20, save: str = None,
         notes: bool = True) -> None:
    """Histogram of one column - shows how its values are spread out.

    Example:  kami.hist(df, 'attack')
    """
    _require_columns(data, [column])
    if notes:
        yours = None
        s = data[column].dropna()
        if pd.api.types.is_numeric_dtype(data[column]) and len(s):
            yours = (f"'{column}' ranges from {s.min():.0f} to {s.max():.0f}, with most "
                     f"values near {s.median():.0f}.")
        _chart_note(
            f"how often each range of '{column}' shows up - tall bars are common values, and "
            f"the overall spread tells you how varied '{column}' is.",
            yours,
        )
    plt = _get_plt()
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.hist(data[column].dropna(), bins=bins, edgecolor="white")
    ax.set_xlabel(column)
    ax.set_ylabel("count")
    ax.set_title(f"Distribution of {column}")
    _finish_plot(fig, plt, save)


def bar(data: pd.DataFrame, x: str, y: str = None, save: str = None,
        notes: bool = True) -> None:
    """Bar chart. With only x, shows how many rows fall in each category of x.
    With x and y, shows the average y for each category of x.

    Examples:
        kami.bar(df, x='type')                 # how many of each type
        kami.bar(df, x='type', y='attack')     # average attack per type
    """
    cols = [x] if y is None else [x, y]
    _require_columns(data, cols)
    if notes:
        if y is None:
            vc = data[x].value_counts()
            yours = (f"'{vc.index[0]}' is the most common '{x}' ({int(vc.iloc[0])} rows)."
                     if len(vc) else None)
            what = (f"each bar is how many rows fall in each '{x}' category - taller means "
                    f"more common.")
        else:
            what = (f"each bar is the average '{y}' for each '{x}' category - taller means a "
                    f"higher average.")
            yours = None
        _chart_note(what, yours)
    plt = _get_plt()
    if y is None:
        counts = data[x].value_counts().head(20)
        heights, labels, ylab, title = counts.values, counts.index, "count", f"Count by {x}"
    else:
        means = data.groupby(x)[y].mean().sort_values(ascending=False).head(20)
        heights, labels, ylab, title = means.values, means.index, f"average {y}", f"Average {y} by {x}"
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.bar([str(v) for v in labels], heights, edgecolor="white")
    ax.set_xlabel(x)
    ax.set_ylabel(ylab)
    ax.set_title(title)
    if len(labels) > 6:
        plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
    _finish_plot(fig, plt, save)


def plot_predictions(result, save: str = None, notes: bool = True) -> None:
    """Show how good a trained model is, on the held-out test rows.

    Regression  -> a 'predicted vs actual' scatter (points near the line = good).
    Classification -> a confusion matrix (big numbers on the diagonal = good).

    Example:  result = kami.train(df, target='price'); kami.plot_predictions(result)
    """
    if not isinstance(result, KamiResult):
        raise ValueError(
            "kami.plot_predictions() expects the result from kami.train():\n"
            "   result = kami.train(df, target='...')\n"
            "   kami.plot_predictions(result)"
        )
    if result._eval_actual is None or result._eval_pred is None:
        raise ValueError("This result has no saved test data to plot. Re-run kami.train(...) first.")

    plt = _get_plt()
    actual, pred, target = result._eval_actual, result._eval_pred, result.target

    if result.task == "regression":
        if notes:
            yours = None
            if result.rmse is not None:
                yours = (f"the closer the dots hug the line, the better. Here they're "
                         f"typically about {result.rmse:.0f} off (R2 = {result.score:.2f}).")
            _chart_note(
                f"each dot is one test row: its real '{target}' (left-right) against the "
                f"model's prediction (up-down). The dashed line is a perfect prediction - "
                f"dots above it are over-estimates, below it are under-estimates.",
                yours,
            )
        fig, ax = plt.subplots(figsize=(6, 6))
        ax.scatter(actual, pred, alpha=0.6, edgecolor="none")
        lo = float(min(np.min(actual), np.min(pred)))
        hi = float(max(np.max(actual), np.max(pred)))
        ax.plot([lo, hi], [lo, hi], "--", color="grey", linewidth=1.5,
                label="perfect prediction")
        ax.set_xlabel(f"actual {target}")
        ax.set_ylabel(f"predicted {target}")
        ax.set_title(f"Predicted vs actual {target}  (R2 = {result.score:.3f})")
        ax.legend()
        _finish_plot(fig, plt, save)
        return

    # classification -> confusion matrix
    from sklearn.metrics import confusion_matrix
    if notes:
        _chart_note(
            f"a grid of real answer (rows) against the model's guess (columns). The diagonal "
            f"top-left to bottom-right is where it got things right - bigger numbers there are "
            f"better; anything off the diagonal is a mistake.",
            f"overall it was right {result.score:.0%} of the time on unseen rows.",
        )
    labels = sorted(pd.unique(np.concatenate([actual, pred])).tolist())
    cm = confusion_matrix(actual, pred, labels=labels)
    fig, ax = plt.subplots(figsize=(6, 5.5))
    im = ax.imshow(cm, cmap="Blues")
    ax.set_xticks(range(len(labels)), [str(l) for l in labels], rotation=45, ha="right")
    ax.set_yticks(range(len(labels)), [str(l) for l in labels])
    ax.set_xlabel(f"predicted {target}")
    ax.set_ylabel(f"actual {target}")
    ax.set_title(f"Confusion matrix  (accuracy = {result.score:.1%})")
    thresh = cm.max() / 2 if cm.max() else 0
    for i in range(len(labels)):
        for j in range(len(labels)):
            ax.text(j, i, str(cm[i, j]), ha="center", va="center",
                    color="white" if cm[i, j] > thresh else "black")
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
    _finish_plot(fig, plt, save)


def compare(results, save: str = None) -> None:
    """Compare several trained models on one bar chart, so you can see which did best.

    `results` can be a dict {label: result} or a list of results. They must all be the
    same kind of problem (all regression, or all classification).

    Example:
        a = kami.train(df, target='price', model='RandomForestRegressor')
        b = kami.train(df, target='price', model='Ridge')
        kami.compare({'Random forest': a, 'Ridge': b})
    """
    if isinstance(results, dict):
        items = list(results.items())
    elif isinstance(results, (list, tuple)):
        items = [(r.model_name, r) for r in results]
    else:
        raise ValueError("Pass a dict {label: result} or a list of results from kami.train().")
    if not items:
        raise ValueError("No results to compare - train at least two models first.")
    for _, r in items:
        if not isinstance(r, KamiResult):
            raise ValueError("Every item must be a result from kami.train().")
    tasks = {r.task for _, r in items}
    if len(tasks) > 1:
        raise ValueError(
            "These models aren't comparable: some are regression and some are classification. "
            "Compare like with like."
        )

    task = items[0][1].task
    metric = "R2 (higher is better)" if task == "regression" else "accuracy (higher is better)"
    labels = [str(lbl) for lbl, _ in items]
    scores = [r.score for _, r in items]
    plt = _get_plt()
    fig, ax = plt.subplots(figsize=(8, 5))
    ax.bar(labels, scores, edgecolor="white")
    ax.set_ylabel(metric)
    ax.set_title("Model comparison")
    if len(labels) > 6:
        plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
    for i, s in enumerate(scores):
        ax.text(i, s, f"{s:.3f}", ha="center", va="bottom")
    _finish_plot(fig, plt, save)


_LESSONS = {
    "linear": {
        "title": "Linear Regression", "teaser": "a straight-line fit for predicting numbers",
        "difficulty": "Beginner",
        "idea": "Picture every house as a dot - size along the bottom, price up the side. "
                "Now lay a single straight ruler through the cloud of dots so it sits as close "
                "to all of them as it can. That ruler is your model: to price a new house, you "
                "read the value off the ruler at its size.",
        "how": "It finds the one straight line (or flat tilt across many columns) that makes "
               "its total miss across all your rows as small as possible. Each column gets a "
               "weight saying how much it pushes the answer up or down.",
        "use_when": ["The relationship is roughly a straight line",
                     "You want a simple model you can explain", "You have only a few columns"],
        "avoid_when": ["The pattern curves or is complex", "There are lots of extreme outliers"],
        "try_it": 'result = kami.train(df, target="price", model="LinearRegression")',
    },
    "ridge": {
        "title": "Ridge Regression", "teaser": "linear regression that resists overfitting",
        "difficulty": "Beginner",
        "idea": "Linear Regression with a seatbelt. Plain linear regression can swing wildly "
                "when two columns say almost the same thing - handing one a huge positive weight "
                "and the other a huge negative one. Ridge gently pulls every weight back toward "
                "zero, so no single column can yank the prediction around.",
        "how": "Same line-fitting as linear regression, but it adds a penalty for large weights, "
               "trading a touch of training accuracy for a steadier, more reliable model.",
        "use_when": ["Your columns overlap or move together", "Plain linear regression overfits"],
        "avoid_when": ["The pattern isn't roughly a straight line"],
        "try_it": 'result = kami.train(df, target="price", model="Ridge")',
    },
    "lasso": {
        "title": "Lasso Regression", "teaser": "linear regression that drops useless columns",
        "difficulty": "Beginner",
        "idea": "Lasso packs light. Give it ten columns when only three matter, and it quietly "
                "sets the other seven weights to exactly zero - dropping them from the model "
                "entirely. You're left with a short, readable shortlist of what actually counts.",
        "how": "Like Ridge, but its penalty can push a weight all the way to zero, so it doubles "
               "as automatic feature selection while it fits the line.",
        "use_when": ["You suspect only a few columns matter", "You want the simplest model possible"],
        "avoid_when": ["Many correlated columns are useful (it may drop one at random)"],
        "try_it": 'result = kami.train(df, target="price", model="Lasso")',
    },
    "logistic": {
        "title": "Logistic Regression", "teaser": "a simple, strong classifier with probabilities",
        "difficulty": "Beginner",
        "idea": "Two crowds on a map - say churners and stayers - and you draw a single straight "
                "fence that best separates them. For a new person, which side do they land on? "
                "Logistic Regression draws that fence and, better still, tells you how sure it is "
                "(90% likely to churn), not just the label.",
        "how": "Despite the name, it's a classifier. It finds a straight boundary between the "
               "groups and outputs a probability for each class.",
        "use_when": ["Sorting rows into two or more categories", "You want probabilities, not just labels",
                     "You need a fast, strong baseline"],
        "avoid_when": ["The groups are tangled and can't be split by a straight line"],
        "try_it": 'result = kami.train(df, target="churn", model="LogisticRegression")',
    },
    "knn": {
        "title": "K-Nearest-Neighbors (KNN)", "teaser": "predicts from the most similar past rows",
        "difficulty": "Beginner",
        "idea": "A new kid moves to town. To guess their favourite music, you look at the five "
                "neighbours living closest and go with the majority. KNN does exactly that: to "
                "predict a new row, it finds the handful of most similar rows it has already seen "
                "and lets them vote (or averages their numbers).",
        "how": "There's no real training step - it just remembers the data. At prediction time it "
               "measures which stored rows are 'nearest' by their column values.",
        "use_when": ["Similar rows tend to have similar answers", "Your dataset isn't huge",
                     "Your columns are on similar scales"],
        "avoid_when": ["Big datasets (it's slow - it compares against everything)",
                       "There are many columns", "Columns are on wildly different scales"],
        "try_it": 'result = kami.train(df, target="price", model="KNeighborsRegressor")',
    },
    "decisiontree": {
        "title": "Decision Tree", "teaser": "a flowchart of yes/no questions",
        "difficulty": "Beginner",
        "idea": "Twenty questions. 'Is the house bigger than 2,000 sq ft?' Yes -> 'Does it have a "
                "garage?' No -> ... Each yes/no answer sends you down a branch until you reach a "
                "guess at the bottom. A Decision Tree builds that flowchart automatically, always "
                "asking the question that best splits the answers.",
        "how": "It repeatedly splits the data on the single question that most cleanly separates "
               "the target, growing a tree of yes/no rules.",
        "use_when": ["You want a model you can read and explain", "You have mixed numbers and categories",
                     "The pattern is non-linear"],
        "avoid_when": ["You need top accuracy - a single tree overfits (use a forest instead)"],
        "try_it": 'result = kami.train(df, target="price", model="DecisionTreeRegressor")',
    },
    "randomforest": {
        "title": "Random Forest", "teaser": "a voting crowd of trees (Kami's default)",
        "difficulty": "Beginner", "tag": "Kami's default model",
        "idea": "Ask one person to guess and they might be biased. Ask a hundred different people "
                "and average their guesses, and the errors tend to cancel out. A Random Forest "
                "grows hundreds of decision trees, each on a random slice of your rows and "
                "columns, then averages their votes. The crowd beats the individual.",
        "how": "Many trees, each trained on a random subsample, combined by voting or averaging - "
               "which cancels out any single tree's mistakes and rarely overfits.",
        "use_when": ["Almost any table", "Mixed data and missing values",
                     "You want strong results with no tuning"],
        "avoid_when": ["You need maximum speed", "You need a model you can read as one simple rule"],
        "try_it": 'result = kami.train(df, target="price", model="RandomForestRegressor")',
    },
    "gradientboosting": {
        "title": "Gradient Boosting", "teaser": "trees that fix each other's mistakes",
        "difficulty": "Intermediate",
        "idea": "Write a rough first draft, then revise - each pass fixing the worst mistakes "
                "left over. Gradient Boosting builds trees one at a time, and every new tree "
                "focuses on the rows the model is still getting wrong. Slowly, the team of trees "
                "sands down the errors.",
        "how": "Trees are added in sequence, each correcting the leftover errors of the ones "
               "before it. Often the most accurate model on tables - but more sensitive to its "
               "settings.",
        "use_when": ["You want top accuracy and will tune a little", "The pattern is non-linear"],
        "avoid_when": ["You want a quick, no-fuss model", "You can't watch carefully for overfitting"],
        "try_it": 'result = kami.train(df, target="price", model="GradientBoostingRegressor")',
    },
    "naivebayes": {
        "title": "Naive Bayes", "teaser": "a fast probability-based classifier",
        "difficulty": "Beginner",
        "idea": "Guess the fruit from clues. Round? Orange-coloured? Tennis-ball sized? Each clue "
                "nudges your guess toward 'orange'. Naive Bayes weighs each column's clue "
                "separately and multiplies them together, 'naively' assuming the clues don't "
                "depend on one another.",
        "how": "It uses probability (Bayes' rule) and treats each column as independent evidence - "
               "which makes it extremely fast, even with many columns.",
        "use_when": ["You want a quick baseline", "There are lots of columns, or text/word counts",
                     "You have little data"],
        "avoid_when": ["Columns are strongly related (the independence assumption breaks)",
                       "You need well-calibrated probabilities"],
        "try_it": 'result = kami.train(df, target="churn", model="GaussianNB")',
    },
    "svm": {
        "title": "Support Vector Machine (SVM)", "teaser": "finds the widest gap between groups",
        "difficulty": "Intermediate",
        "idea": "Two neighbourhoods, and you want the widest possible empty street between them - "
                "not a fence crammed against the nearest houses, but the boundary with the most "
                "breathing room on both sides. An SVM finds that maximum-margin boundary, and can "
                "curve it to handle trickier shapes.",
        "how": "It places the boundary with the largest gap to the nearest points of each group, "
               "and can bend that boundary for non-linear patterns.",
        "use_when": ["Smaller, scaled datasets", "Many columns", "Complex but clean boundaries"],
        "avoid_when": ["Large datasets (slow)", "You want easy interpretation or feature importance"],
        "try_it": 'result = kami.train(df, target="churn", model="SVC")',
    },
    "neuralnet": {
        "title": "Neural Network (MLP)", "teaser": "flexible layers for very complex patterns",
        "difficulty": "Advanced",
        "idea": "A relay team of tiny decision-makers. Your data passes through layer after layer "
                "of simple units, each one reshaping the signal a little before handing it on. "
                "Stack enough layers and the network can learn remarkably twisty patterns - but "
                "it needs lots of examples and patience.",
        "how": "Layers of weighted connections that adjust themselves during training to fit "
               "complex relationships. Powerful, but data-hungry and hard to interpret.",
        "use_when": ["You have lots of data and complex patterns", "You can afford to tune it"],
        "avoid_when": ["Small tables (it overfits)", "You need speed", "You need to explain the model"],
        "try_it": 'result = kami.train(df, target="price", model="MLPRegressor")',
    },
}

_LESSON_ORDER = ["linear", "ridge", "lasso", "logistic", "knn", "decisiontree",
                 "randomforest", "gradientboosting", "naivebayes", "svm", "neuralnet"]

_LESSON_ALIASES = {
    "linearregression": "linear", "linear": "linear", "ols": "linear",
    "ridge": "ridge", "ridgeregression": "ridge",
    "lasso": "lasso", "lassoregression": "lasso",
    "logisticregression": "logistic", "logistic": "logistic", "logreg": "logistic",
    "kneighbors": "knn", "kneighborsregressor": "knn", "kneighborsclassifier": "knn",
    "knn": "knn", "nearestneighbors": "knn", "knearestneighbors": "knn",
    "decisiontree": "decisiontree", "decisiontreeregressor": "decisiontree",
    "decisiontreeclassifier": "decisiontree", "tree": "decisiontree",
    "randomforest": "randomforest", "randomforestregressor": "randomforest",
    "randomforestclassifier": "randomforest", "forest": "randomforest", "rf": "randomforest",
    "gradientboosting": "gradientboosting", "gradientboostingregressor": "gradientboosting",
    "gradientboostingclassifier": "gradientboosting", "boosting": "gradientboosting", "gbm": "gradientboosting",
    "gaussiannb": "naivebayes", "naivebayes": "naivebayes", "nb": "naivebayes", "bayes": "naivebayes",
    "svr": "svm", "svc": "svm", "svm": "svm", "supportvector": "svm", "supportvectormachine": "svm",
    "mlpregressor": "neuralnet", "mlpclassifier": "neuralnet", "mlp": "neuralnet",
    "neuralnetwork": "neuralnet", "neuralnet": "neuralnet", "nn": "neuralnet",
}


def _print_lesson(fam: str) -> None:
    L = _LESSONS[fam]
    bar = "=" * 70
    _emit(bar)
    _emit(f"KAMI ACADEMY  -  {L['title'].upper()}")
    _emit(bar)
    tag = f"   ({L['tag']})" if L.get("tag") else ""
    _emit(f"Difficulty:   {L['difficulty']}{tag}")
    _emit("")
    _emit("THE IDEA")
    _emit(_wrap(L["idea"], first="   ", indent="   "))
    _emit("")
    _emit("HOW IT WORKS")
    _emit(_wrap(L["how"], first="   ", indent="   "))
    _emit("")
    _emit("GOOD FIT WHEN")
    for s in L["use_when"]:
        _emit(_wrap(f"+ {s}", first="   ", indent="     "))
    _emit("")
    _emit("MAYBE NOT WHEN")
    for s in L["avoid_when"]:
        _emit(_wrap(f"- {s}", first="   ", indent="     "))
    _emit("")
    _emit("TRY IT")
    _emit(f"   {L['try_it']}")
    _emit(bar)


def learn(model_name: str = None) -> None:
    """Kami Academy: plain-English, story-first lessons on the models Kami offers.

    Each lesson opens with a vivid picture of how the model thinks, then covers how
    it really works, when it's a good fit, when to avoid it, and a line to try it.
    Call with no name to see the full list.

    Example:  kami.learn('RandomForest')   or   kami.learn()
    """
    if model_name is None or str(model_name).strip() == "":
        _emit("KAMI ACADEMY - call kami.learn('name') for a lesson on any of these:")
        _emit("")
        for fam in _LESSON_ORDER:
            _emit(f"   {_LESSONS[fam]['title']:<28} {_LESSONS[fam]['teaser']}")
        _emit("")
        _emit("Example:  kami.learn('RandomForest')")
        _emit("Confused by a word?  kami.translate('overfitting')")
        return None
    key = str(model_name).strip().lower().replace(" ", "").replace("_", "").replace("-", "")
    fam = _LESSON_ALIASES.get(key)
    if fam is None:
        titles = [_LESSONS[f]["title"] for f in _LESSON_ORDER]
        raise ValueError(
            f"I don't have a lesson for '{model_name}'.\n"
            f"   Try one of: {titles}\n"
            f"   Or run kami.learn() with no name to see the list."
        )
    _print_lesson(fam)
    return None


# ======================================================================
# PLAIN-ENGLISH DICTIONARY  -  kami.translate('term')
# ======================================================================

_GLOSSARY = {
    "overfitting": {
        "term": "Overfitting",
        "plain": "The model memorised the training data instead of learning the general "
                 "pattern - so it looks great on data it has seen, but does poorly on new data.",
        "why": "It gives false confidence: it will likely disappoint on the data you actually "
               "care about. Cross-validation helps you catch it.",
    },
    "underfitting": {
        "term": "Underfitting",
        "plain": "The model is too simple to capture the pattern, so it does poorly even on the "
                 "training data.",
        "why": "Usually a sign to try a more flexible model (like a random forest) or add "
               "better columns.",
    },
    "feature": {
        "term": "Feature",
        "plain": "Just another word for an input column - one piece of information the model "
                 "uses to make its prediction.",
    },
    "target": {
        "term": "Target",
        "plain": "The column you're trying to predict - the answer the model learns to produce.",
    },
    "regression": {
        "term": "Regression",
        "plain": "Predicting a number, like price, age, or temperature.",
    },
    "classification": {
        "term": "Classification",
        "plain": "Sorting each row into a category, like spam vs not-spam, or which species.",
    },
    "r squared": {
        "term": "R-squared (R2)",
        "plain": "A regression score, usually 0 to 1: roughly the share of the target's "
                 "variation the model explains. 0.8 means it explains about 80%.",
        "why": "Higher is better. 1.0 is perfect; 0 or below means no better than guessing the "
               "average every time.",
    },
    "rmse": {
        "term": "RMSE (root mean squared error)",
        "plain": "The typical size of the model's mistake, in the same units as the target. "
                 "RMSE of 19 means it's usually off by about 19.",
        "why": "Lower is better, and it's in your target's units - so it's easy to feel whether "
               "that error is acceptable.",
    },
    "mae": {
        "term": "MAE (mean absolute error)",
        "plain": "The average size of the model's mistakes, in the target's units. Like RMSE, "
                 "but less swayed by the occasional huge miss.",
    },
    "baseline": {
        "term": "Baseline",
        "plain": "The score you'd get from a trivial guess - the average (for numbers) or the "
                 "most common answer (for categories). Your model should beat it.",
        "why": "If a model can't beat the baseline, it hasn't learned anything useful.",
    },
    "cross validation": {
        "term": "Cross-validation",
        "plain": "Testing the model on several different splits of the data and averaging the "
                 "scores, instead of trusting one lucky or unlucky split.",
        "why": "It gives a more trustworthy estimate of how the model will do on new data.",
    },
    "train test split": {
        "term": "Train/test split",
        "plain": "Holding back part of your data to test on, so the model is graded on rows it "
                 "never saw while training.",
        "why": "Testing on data the model already learned from would give a falsely high score.",
    },
    "leakage": {
        "term": "Data leakage",
        "plain": "When a column secretly carries information about the answer that you wouldn't "
                 "really have at prediction time - so the model 'cheats'.",
        "why": "It makes scores look amazing in testing but useless in the real world. Watch for "
               "columns worked out from the target.",
    },
    "one hot encoding": {
        "term": "One-hot encoding",
        "plain": "Turning a text column into numbers by making a separate 0/1 column for each "
                 "category (e.g. colour -> colour_red, colour_blue).",
        "why": "Models do maths, not words, so categories must become numbers first. Kami does "
               "this for you.",
    },
    "accuracy": {
        "term": "Accuracy",
        "plain": "For classification: the share of predictions the model got right (77% means "
                 "77 of every 100).",
        "why": "Easy to read, but can mislead when one category is much more common - always "
               "check it against the baseline.",
    },
    "precision": {
        "term": "Precision",
        "plain": "Of the rows the model labelled positive, how many really were. High precision "
                 "means few false alarms.",
    },
    "recall": {
        "term": "Recall",
        "plain": "Of the rows that really were positive, how many the model caught. High recall "
                 "means few misses.",
    },
    "bias": {
        "term": "Bias (high bias)",
        "plain": "A model that's too simple and misses the real pattern - closely related to "
                 "underfitting.",
    },
    "variance": {
        "term": "Variance (high variance)",
        "plain": "A model that's too sensitive to the exact training rows and changes a lot when "
                 "the data changes - closely related to overfitting.",
    },
    "hyperparameter": {
        "term": "Hyperparameter",
        "plain": "A setting you choose before training (like how many trees in a forest), as "
                 "opposed to something the model learns from the data itself.",
    },
    "outlier": {
        "term": "Outlier",
        "plain": "A row with an unusually extreme value, far from the rest. It can pull some "
                 "models off course.",
    },
    "imbalance": {
        "term": "Class imbalance",
        "plain": "When one category vastly outnumbers the others (say 95% 'no', 5% 'yes').",
        "why": "It can make accuracy look high while the model quietly ignores the rare class - "
               "the one you often care about most.",
    },
    "scaling": {
        "term": "Scaling (normalisation)",
        "plain": "Putting numeric columns on a similar range so no single column dominates just "
                 "because its numbers are bigger.",
        "why": "It matters a lot for distance-based models like KNN and SVM.",
    },
    "ensemble": {
        "term": "Ensemble",
        "plain": "Combining many models into one (like a forest of trees) so their individual "
                 "mistakes cancel out.",
        "why": "Usually more accurate and steadier than any single model.",
    },
    "feature importance": {
        "term": "Feature importance",
        "plain": "A ranking of which columns influenced the model's predictions the most.",
        "why": "It hints at what's driving the target - but read it as 'related to', not proven "
               "cause.",
    },
}

_GLOSSARY_ORDER = [
    "target", "feature", "regression", "classification", "r squared", "rmse", "mae",
    "accuracy", "precision", "recall", "baseline", "overfitting", "underfitting",
    "bias", "variance", "cross validation", "train test split", "leakage",
    "one hot encoding", "imbalance", "scaling", "hyperparameter", "outlier",
    "ensemble", "feature importance",
]

_GLOSSARY_ALIASES = {
    "r2": "r squared", "rsquared": "r squared", "r 2": "r squared",
    "root mean squared error": "rmse",
    "mean absolute error": "mae",
    "data leakage": "leakage", "target leakage": "leakage",
    "onehot": "one hot encoding", "one hot": "one hot encoding", "onehotencoding": "one hot encoding",
    "normalization": "scaling", "normalisation": "scaling", "normalize": "scaling", "standardization": "scaling",
    "class imbalance": "imbalance", "imbalanced": "imbalance",
    "high bias": "bias", "high variance": "variance",
    "cv": "cross validation", "k fold": "cross validation", "kfold": "cross validation",
    "holdout": "train test split", "train/test split": "train test split",
    "params": "hyperparameter", "parameter": "hyperparameter", "hyperparameters": "hyperparameter",
    "outliers": "outlier", "features": "feature",
}


def translate(term: str = None) -> None:
    """Plain-English translations of data-science jargon.

    Call with no word to see the list, or pass a term:  kami.translate('overfitting')
    """
    if term is None or str(term).strip() == "":
        _emit("Plain-English translations - call kami.translate('word'):")
        _emit("")
        line, row = "   ", []
        for key in _GLOSSARY_ORDER:
            row.append(_GLOSSARY[key]["term"])
        for t in row:
            _emit(f"   - {t}")
        _emit("")
        _emit("Example:  kami.translate('overfitting')")
        return None
    norm = " ".join(str(term).strip().lower().replace("-", " ").replace("_", " ").split())
    key = norm if norm in _GLOSSARY else _GLOSSARY_ALIASES.get(norm)
    entry = _GLOSSARY.get(key) if key else None
    if entry is None:
        known = [_GLOSSARY[k]["term"] for k in _GLOSSARY_ORDER]
        raise ValueError(
            f"I don't have a translation for '{term}' yet.\n"
            f"   I know: {known}\n"
            f"   Or run kami.translate() with no word to see the list."
        )
    _emit(f"'{entry['term']}' in plain English:")
    _emit(_wrap(entry["plain"], first="   ", indent="   "))
    if entry.get("why"):
        _emit("")
        _emit("Why it matters:")
        _emit(_wrap(entry["why"], first="   ", indent="   "))
    return None


def feature_importance(result, top: int = 15, save: str = None, chart: bool = True) -> None:
    """Show which columns the model leaned on most, ranked from strongest down.

    Prints a plain-English ranking (strong / moderate / minor influence, with the
    direction for linear models) and, unless chart=False, draws a bar chart too.

    Works for tree-based models (RandomForest, GradientBoosting, DecisionTree) and
    the linear models (which expose coefficients). Models like KNN or SVR don't
    provide this - re-train with a tree-based model to see it.

    Example:  result = kami.train(df, target='price'); kami.feature_importance(result)
    """
    if not isinstance(result, KamiResult):
        raise ValueError("kami.feature_importance() expects the result from kami.train().")
    model = result.raw
    names = list(result.feature_columns)
    signed = None
    if hasattr(model, "feature_importances_"):
        vals = np.asarray(model.feature_importances_, dtype=float)
        kind = "importance (share of the decisions)"
        mode = "tree"
    elif hasattr(model, "coef_"):
        coef = np.asarray(model.coef_, dtype=float)
        if coef.ndim == 1:
            signed = coef
        elif coef.shape[0] == 1:
            signed = coef[0]
        vals = np.abs(coef).mean(axis=0) if coef.ndim > 1 else np.abs(coef)
        kind = "weight (size of the coefficient)"
        mode = "linear"
    else:
        fallback = "RandomForestRegressor" if result.task == "regression" else "RandomForestClassifier"
        raise ValueError(
            f"{result.model_name} doesn't expose feature importances. To see which "
            f"columns matter, re-train with a tree-based model, e.g. model='{fallback}'."
        )

    # --- plain-English ranking (no chart needed) ---
    mx = float(vals.max()) or 1.0
    total = float(vals.sum()) or 1.0

    def _label(rel):
        if rel >= 0.66:
            return "strong"
        if rel >= 0.33:
            return "moderate"
        return "minor"

    order = np.argsort(vals)[::-1][:top]
    pairs = []
    for rank, i in enumerate(order, 1):
        lab = _label(vals[i] / mx)
        if mode == "tree":
            value_str = f"{lab} influence ({vals[i] / total:.0%})"
        elif signed is not None:
            value_str = f"{lab} influence ({'+' if signed[i] >= 0 else '-'})"
        else:
            value_str = f"{lab} influence"
        pairs.append((f"{rank}. {names[i]}", value_str))
    _chart_note(
        f"a ranking of which columns the model leaned on most when predicting "
        f"'{result.target}', strongest first. Read it as 'related to', not proven cause - a "
        f"column can rank high because it moves alongside the real driver."
    )
    _show_scorecard(pairs, "guide",
                    title=f"WHICH COLUMNS MATTER MOST (for '{result.target}')")
    top_name = names[int(np.argmax(vals))]
    _emit(_wrap(f"Kami: '{top_name}' is most associated with '{result.target}'."))
    if mode == "linear" and signed is not None:
        _emit(_wrap(
            "    (+ means higher values push the prediction up, - means down. A "
            "coefficient's size also depends on that column's units, so compare with care.)",
            first="    ", indent="    "))
    elif mode == "tree":
        _emit(_wrap(
            "    Importance is the share of the model's decisions a column drives - it "
            "shows strength, not direction (whether it pushes the answer up or down).",
            first="    ", indent="    "))

    if not chart:
        return None

    # --- bar chart ---
    bar_names = [str(names[i]) for i in order][::-1]      # reversed: strongest at the top
    bar_vals = [vals[i] for i in order][::-1]
    plt = _get_plt()
    fig, ax = plt.subplots(figsize=(8, max(3, 0.4 * len(bar_names))))
    ax.barh(bar_names, bar_vals, edgecolor="white")
    ax.set_xlabel(kind)
    ax.set_title(f"Top features driving '{result.target}'")
    _finish_plot(fig, plt, save)
    return None


def _auto_whatif_values(result, base_row: dict, feature: str):
    """Pick a sensible set of values to sweep a feature across, if the user gives none."""
    base = base_row.get(feature, result._impute.get(feature))
    if isinstance(base, bool):
        return [False, True]
    # categorical: recover the categories seen during training from the encoded columns
    if feature in result._text_cols:
        prefix = feature + "_"
        cats = [c[len(prefix):] for c in result.feature_columns if c.startswith(prefix)]
        return cats or None
    # numeric: spread around the base value (or the training median if base is missing)
    try:
        base = float(base)
    except (TypeError, ValueError):
        return None
    if base == 0:
        return [0, 1, 2, 3, 4]
    vals = [0.5 * base, 0.75 * base, base, 1.25 * base, 1.5 * base]
    return [round(v, 2) if abs(v) < 1000 else round(v) for v in vals]


def whatif(result, base_row: dict, feature: str, values=None, show: bool = True):
    """See how the prediction changes as you vary ONE input column.

    Hold the other columns fixed at base_row, sweep `feature` across `values` (or an
    automatic range), and show the predicted result at each step. A simple way to ask
    'what would happen to the prediction if this column were higher/lower/different?'

    Example:
        result = kami.train(df, target='price')
        kami.whatif(result, {'sqft': 1500, 'bedrooms': 3}, feature='sqft',
                    values=[1000, 1500, 2000, 2500])
    """
    if not isinstance(result, KamiResult):
        raise ValueError(
            "kami.whatif() expects the result from kami.train() as its first argument."
        )
    if not isinstance(base_row, dict):
        raise ValueError(
            "base_row should be a dict of your input columns, "
            "e.g. {'sqft': 1500, 'bedrooms': 3}."
        )
    if feature not in result.input_columns:
        raise ValueError(
            f"'{feature}' isn't one of the columns this model was trained on.\n"
            f"   You can vary any of: {result.input_columns}"
        )
    if values is None:
        values = _auto_whatif_values(result, base_row, feature)
    if not values:
        raise ValueError(
            f"I couldn't auto-pick values for '{feature}'. Pass some yourself, "
            f"e.g. values=[...]."
        )

    rows = []
    for v in values:
        row = dict(base_row)
        row[feature] = v
        rows.append((v, result.predict(row, show=False)[0]))

    if show:
        def _fmt(p):
            return f"{p:.3f}" if result.task == "regression" else str(p)

        _show_scorecard(
            [(f"{feature} = {v}", _fmt(p)) for v, p in rows], "guide",
            title=f"WHAT-IF: predicted '{result.target}' as '{feature}' changes",
        )
        first_v, first_p = rows[0]
        last_v, last_p = rows[-1]
        is_cat = feature in result._text_cols
        if result.task == "regression":
            if is_cat:
                best_v, best_p = max(rows, key=lambda r: r[1])
                worst_v, worst_p = min(rows, key=lambda r: r[1])
                _emit(_wrap(
                    f"Kami: Predicted '{result.target}' is highest at '{feature}' = {best_v} "
                    f"({best_p:.3f}) and lowest at '{feature}' = {worst_v} ({worst_p:.3f})."))
            else:
                diff = last_p - first_p
                direction = "rises" if diff > 0 else ("falls" if diff < 0 else "barely moves")
                _emit(_wrap(
                    f"Kami: As '{feature}' goes from {first_v} to {last_v}, the predicted "
                    f"'{result.target}' {direction} from {first_p:.3f} to {last_p:.3f} "
                    f"(a change of {diff:+.3f})."))
        else:
            distinct = list(dict.fromkeys(p for _, p in rows))
            if len(distinct) == 1:
                _emit(_wrap(
                    f"Kami: Across these values of '{feature}', the predicted '{result.target}' "
                    f"stays {distinct[0]} - on its own, this column doesn't change the outcome "
                    f"here."))
            else:
                _emit(_wrap(
                    f"Kami: The predicted '{result.target}' changes as '{feature}' changes - "
                    f"the table above shows which value gives which prediction."))
        _emit(_wrap("    (Every other column was held fixed at your base_row.)",
                    first="    ", indent="    "))
    return rows


def quickplot(data: pd.DataFrame, max_panels: int = 6, save: str = None) -> None:
    """A fast visual overview of a dataset, in one grid.

    Draws a histogram for each numeric column and a bar chart of the top categories
    for each text column (up to max_panels). A quick way to eyeball your data before
    you dig in.

    Example:  kami.quickplot(df)
    """
    if not isinstance(data, pd.DataFrame):
        raise ValueError("kami.quickplot expects your data as a table (a pandas DataFrame).")
    num_cols = list(data.select_dtypes("number").columns)
    text_cols = [
        c for c in data.columns
        if not pd.api.types.is_numeric_dtype(data[c])
        and not pd.api.types.is_datetime64_any_dtype(data[c])
    ]
    panels = [("hist", c) for c in num_cols] + [("bar", c) for c in text_cols]
    if not panels:
        raise ValueError(
            "Nothing to plot - kami.quickplot needs at least one numeric or text column."
        )
    panels = panels[:max_panels]
    _emit(_wrap(
        f"Kami: Quick look at {len(data):,} rows - showing {len(panels)} of your "
        f"{data.shape[1]} columns ({len(num_cols)} numeric, {len(text_cols)} text)."))

    n = len(panels)
    ncols = 1 if n == 1 else (2 if n <= 4 else 3)
    nrows = (n + ncols - 1) // ncols
    plt = _get_plt()
    fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 3.2 * nrows))
    axes = np.atleast_1d(axes).ravel()
    for ax, (kind, col) in zip(axes, panels):
        if kind == "hist":
            vals = data[col].dropna()
            bins = min(30, max(5, int(np.sqrt(max(len(vals), 1)))))
            ax.hist(vals, bins=bins, edgecolor="white")
            ax.set_title(f"{col} (distribution)")
        else:
            counts = data[col].value_counts().head(8)
            ax.bar([str(i) for i in counts.index], counts.values, edgecolor="white")
            ax.set_title(f"{col} (counts)")
            ax.tick_params(axis="x", rotation=45)
    for ax in axes[n:]:
        ax.axis("off")
    fig.suptitle("Quick look at your data")
    fig.tight_layout()
    _finish_plot(fig, plt, save)
    return None


def suggest_charts(data: pd.DataFrame) -> None:
    """Suggest which charts suit your data, with ready-to-run examples.

    Looks at your column types and recommends the charts that make sense, plus a note
    on which read best for different audiences.

    Example:  kami.suggest_charts(df)
    """
    if not isinstance(data, pd.DataFrame):
        raise ValueError("kami.suggest_charts expects your data as a table (a pandas DataFrame).")
    num = list(data.select_dtypes("number").columns)
    cat = [c for c in data.columns
           if not pd.api.types.is_numeric_dtype(data[c])
           and not pd.api.types.is_datetime64_any_dtype(data[c])
           and data[c].nunique(dropna=True) <= 30]
    dates = [c for c in data.columns if pd.api.types.is_datetime64_any_dtype(data[c])]
    if not (num or cat or dates):
        raise ValueError(
            "I couldn't find numeric, category, or date columns to chart. "
            "kami.clean(df) sometimes helps if columns came in as the wrong type."
        )

    _emit(_wrap(f"Kami: Chart ideas for your data ({len(num)} numeric, "
                f"{len(cat)} category column(s)):"))
    _emit("")

    if num or cat:
        _emit("   See how ONE column is spread out:")
        if num:
            _emit(f"     kami.hist(df, '{num[0]}')        # the shape of a number column")
        if cat:
            _emit(f"     kami.bar(df, '{cat[0]}')         # how many rows per category")
            _emit(f"     kami.pie(df, '{cat[0]}')         # the same, as shares of the whole")
        _emit("")

    rel = []
    if len(num) >= 2:
        rel.append(f"     kami.scatter(df, '{num[0]}', '{num[1]}')   # do two numbers move together?")
        rel.append("     kami.heatmap(df)                  # every numeric pair at once")
    if cat and num:
        rel.append(f"     kami.bar(df, '{cat[0]}', '{num[0]}')   # average '{num[0]}' per '{cat[0]}'")
    if dates and num:
        rel.append(f"     kami.line(df, '{dates[0]}', '{num[0]}')   # '{num[0]}' over time")
    if rel:
        _emit("   See how columns RELATE to each other:")
        for r in rel:
            _emit(r)
        _emit("")

    _emit("   A quick overview of everything at once:")
    _emit("     kami.quickplot(df)")
    _emit("")
    _emit(_wrap(
        "Tip: bars and pies read most easily for non-technical audiences; scatter plots and "
        "heatmaps are better for spotting relationships while you analyse.",
        first="   ", indent="   "))
    return None


def heatmap(data: pd.DataFrame, save: str = None, notes: bool = True) -> None:
    """Correlation heatmap of the numeric columns - shows which ones move together.

    Strong colours (near +1 or -1) mean two columns are closely related. It's also
    a quick way to spot a feature that's suspiciously close to your target.

    Example:  kami.heatmap(df)
    """
    if not isinstance(data, pd.DataFrame):
        raise ValueError("kami.heatmap expects your data as a table (a pandas DataFrame).")
    num = data.select_dtypes("number")
    if num.shape[1] < 2:
        raise ValueError(
            "Need at least two numeric columns to show correlations - this data has "
            f"{num.shape[1]}."
        )
    corr = num.corr()
    if notes:
        yours = None
        try:
            c = corr.abs().to_numpy().copy()
            np.fill_diagonal(c, 0.0)
            idx = np.unravel_index(int(np.argmax(c)), c.shape)
            r = float(corr.to_numpy()[idx])
            yours = (f"the strongest link is between '{corr.columns[idx[0]]}' and "
                     f"'{corr.columns[idx[1]]}' (correlation {r:+.2f}).")
        except Exception:
            pass
        _chart_note(
            "how strongly each pair of numeric columns moves together. Warm (near +1) means "
            "they rise together; cool (near -1) means one rises as the other falls; pale/near "
            "zero means little link. A value very close to your target can be a leakage warning.",
            yours,
        )
    plt = _get_plt()
    size = min(2 + 0.6 * len(corr), 12)
    fig, ax = plt.subplots(figsize=(size, size))
    im = ax.imshow(corr.values, cmap="coolwarm", vmin=-1, vmax=1)
    ax.set_xticks(range(len(corr)), list(corr.columns), rotation=45, ha="right")
    ax.set_yticks(range(len(corr)), list(corr.columns))
    ax.set_title("Correlation between numeric columns")
    if len(corr) <= 12:
        for i in range(len(corr)):
            for j in range(len(corr)):
                v = corr.values[i, j]
                ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=8,
                        color="white" if abs(v) > 0.5 else "black")
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
    _finish_plot(fig, plt, save)


def pie(data: pd.DataFrame, column: str, save: str = None, notes: bool = True) -> None:
    """Pie chart of the share of each category in one column.

    (Bigger categories beyond the top few are grouped into 'Other' so it stays readable.)

    Example:  kami.pie(df, 'type')
    """
    _require_columns(data, [column])
    if notes:
        vc = data[column].value_counts()
        yours = (f"the biggest slice is '{vc.index[0]}' at {vc.iloc[0] / vc.sum():.0%} of rows."
                 if len(vc) else None)
        _chart_note(
            f"each slice is one category of '{column}', sized by how common it is. Easiest to "
            f"read when there are only a few categories.",
            yours,
        )
    counts = data[column].value_counts()
    if len(counts) > 8:
        top = counts.head(7)
        counts = pd.concat([top, pd.Series({"Other": int(counts.iloc[7:].sum())})])
    plt = _get_plt()
    fig, ax = plt.subplots(figsize=(6, 6))
    ax.pie(counts.values, labels=[str(i) for i in counts.index],
           autopct="%1.0f%%", startangle=90)
    ax.set_title(f"Share of {column}")
    _finish_plot(fig, plt, save)


# ======================================================================
# DATA-QUALITY AUDIT  -  kami.audit(df, target='...')
# Checks the common problems that quietly hurt a model and explains each
# one in plain English, with a recommendation.
# ======================================================================

def _pct(n, total) -> str:
    return f"{(100.0 * n / total):.0f}%" if total else "0%"


def audit(data: pd.DataFrame, target: str = None) -> None:
    """Check a dataset for the common problems that quietly hurt a model.

    Reports missing values, duplicate rows, high-cardinality text columns,
    constant columns, ID-like columns, dataset size, and possible leakage -
    each in plain English with a recommendation. Pass target='...' to also
    check for features that look suspiciously close to the answer.

    Example:  kami.audit(df, target='price')
    """
    _require_dataframe(data, "kami.audit")
    if target is not None and target not in data.columns:
        raise ValueError(
            f"There is no column called '{target}'. Your columns are: {list(data.columns)}"
        )

    n_rows, n_cols = len(data), data.shape[1]
    _emit("Kami is auditing your data for common problems...")
    _emit("")
    _emit(f"   Shape: {n_rows:,} rows x {n_cols} columns"
          + (f"   (target: '{target}')" if target else ""))
    findings = []
    features = data.drop(columns=[target]) if target else data

    # 1. missing values
    _emit("\n   1. MISSING VALUES")
    miss = data.isna().sum()
    miss = miss[miss > 0].sort_values(ascending=False)
    if len(miss) == 0:
        _emit("      None - every cell is filled.")
    else:
        for col, n in miss.items():
            _emit(f"      - {col}: {int(n):,} missing ({_pct(n, n_rows)})")
        _emit(_wrap("Recommend: kami.train fills these for you (numbers -> median, text -> "
                    "'Missing'), or use missing='drop' to remove those rows. A column that is "
                    "mostly empty is often better dropped entirely.",
                    first="      ", indent="      "))
        findings.append(f"{len(miss)} column(s) with missing values")

    # 2. duplicate rows
    _emit("\n   2. DUPLICATE ROWS")
    dups = int(data.duplicated().sum())
    if dups == 0:
        _emit("      None.")
    else:
        _emit(f"      - {dups:,} duplicate row(s) ({_pct(dups, n_rows)} of the data).")
        _emit(_wrap("Recommend: duplicates can make a model look better than it is, because "
                    "the same row can land in both the training and testing halves. Consider "
                    "df = df.drop_duplicates().",
                    first="      ", indent="      "))
        findings.append(f"{dups} duplicate rows")

    # 3. high-cardinality text columns
    _emit("\n   3. HIGH-CARDINALITY TEXT COLUMNS")
    text_cols = [c for c in features.columns
                 if not pd.api.types.is_numeric_dtype(features[c])
                 and not pd.api.types.is_bool_dtype(features[c])
                 and not pd.api.types.is_datetime64_any_dtype(features[c])]
    high_card = [(c, int(features[c].nunique())) for c in text_cols if features[c].nunique() > 50]
    if not high_card:
        _emit("      None - text columns have a manageable number of categories.")
    else:
        for c, k in high_card:
            _emit(f"      - {c}: {k:,} distinct values")
        _emit(_wrap("Recommend: one-hot encoding turns each category into its own 0/1 column, "
                    "so a column with very many categories explodes into very many columns - "
                    "which slows training and invites overfitting. Consider grouping rare "
                    "categories into 'Other', or dropping the column if it just labels rows.",
                    first="      ", indent="      "))
        findings.append(f"{len(high_card)} high-cardinality text column(s)")

    # 4. constant / near-constant columns
    _emit("\n   4. CONSTANT / NEAR-CONSTANT COLUMNS")
    flat = []
    for c in features.columns:
        vc = features[c].value_counts(normalize=True, dropna=False)
        if len(vc) <= 1 or float(vc.iloc[0]) >= 0.95:
            flat.append((c, float(vc.iloc[0]) if len(vc) else 1.0))
    if not flat:
        _emit("      None.")
    else:
        for c, frac in flat:
            _emit(f"      - {c}: one value covers {frac * 100:.0f}% of rows")
        _emit(_wrap("Recommend: a column that is almost always the same value carries almost "
                    "no information, so it rarely helps the model - consider dropping it.",
                    first="      ", indent="      "))
        findings.append(f"{len(flat)} near-constant column(s)")

    # 5. ID-like columns
    _emit("\n   5. ID-LIKE COLUMNS")
    id_cols = _find_id_like(features)
    if not id_cols:
        _emit("      None.")
    else:
        _emit(f"      - {id_cols}")
        _emit(_wrap("Recommend: these look like labels - a unique value per row (names, IDs). "
                    "kami.train drops them automatically, because they identify rows rather "
                    "than help predict.",
                    first="      ", indent="      "))

    # 6. dataset size
    _emit("\n   6. DATASET SIZE")
    if n_rows < 50:
        _emit(f"      - Only {n_rows} rows - that's quite small.")
        _emit(_wrap("Scores can be unreliable and models can simply memorise the data. Treat "
                    "results as rough, and gather more rows if you can.",
                    first="        ", indent="        "))
        findings.append("very small dataset")
    elif n_rows < 10 * max(n_cols, 1):
        _emit(f"      - {n_rows} rows for {n_cols} columns is on the small side.")
        _emit(_wrap("A rough rule of thumb is to want many more rows than columns; more data "
                    "usually helps.",
                    first="        ", indent="        "))
        findings.append("few rows relative to columns")
    else:
        _emit(f"      - {n_rows:,} rows looks like enough to train on.")

    # 7. possible leakage (needs a target)
    if target is not None:
        _emit("\n   7. POSSIBLE LEAKAGE")
        leak = []
        if pd.api.types.is_numeric_dtype(data[target]):
            for c in features.select_dtypes("number").columns:
                try:
                    corr = float(data[c].corr(data[target]))
                except Exception:
                    corr = float("nan")
                if pd.notna(corr) and abs(corr) >= 0.95:
                    leak.append(f"{c}: {corr:+.2f} correlation with '{target}'")
        else:
            for c in features.columns:
                if features[c].nunique() <= 1 or features[c].nunique() >= n_rows:
                    continue
                grp = data.groupby(c, observed=True)[target].nunique()
                if (grp <= 1).all():
                    leak.append(f"{c}: each of its values maps to exactly one '{target}'")
        # name-based: a column named like the target is often derived from it
        for c in _name_leak_suspects(target, list(features.columns)):
            note = f"{c}: its name looks derived from '{target}'"
            if not any(str(item).startswith(f"{c}:") for item in leak):
                leak.append(note)
        if not leak:
            _emit("      None obvious from the quick checks.")
        else:
            for item in leak:
                _emit(f"      - {item}")
            findings.append(f"{len(leak)} possible-leakage feature(s)")
        _emit(_wrap("Heads up: a feature this tightly tied to the target MIGHT be leakage - "
                    "information you would not actually have at prediction time (for example, "
                    "a value worked out from the answer). Kami can only flag suspects; you "
                    "decide. Also check that every feature would genuinely be known BEFORE "
                    "the prediction is made.",
                    first="      ", indent="      "))

    # summary
    _emit("\n   " + "-" * 50)
    if findings:
        _emit("   SUMMARY - worth a look:")
        for f in findings:
            _emit(f"      - {f}")
    else:
        _emit("   SUMMARY: no major data-quality problems jumped out. Nice.")


# ======================================================================
# DATASET UNDERSTANDING  -  kami.recommend(df)  and  kami.report(df)
# Help a beginner answer "what's in this data?" and "what should I predict?"
# Both reuse the same internal helpers as look()/audit() - no duplicated logic.
# ======================================================================

_COMMON_TARGET_NAMES = {
    "price", "target", "label", "churn", "outcome", "result", "y", "salary",
    "class", "sales", "score", "rating", "revenue", "profit", "status",
    "is_legendary", "legendary", "default", "survived", "quality", "grade",
    "cost", "amount", "won", "win", "success", "fraud", "spam",
}


_TARGET_NAME_STEMS = (
    "price", "salary", "cost", "amount", "revenue", "profit", "sales", "income",
    "payment", "fee", "charge", "rate", "score", "rating", "churn", "fraud",
    "default", "target", "label", "outcome",
)


def _rank_targets(data: pd.DataFrame):
    """Rank columns by how likely they are to be the thing you'd want to predict.

    Returns a list of (column, task, n_unique) best-guess-first. Shared by
    kami.recommend() and kami.report() so the logic lives in one place.
    """
    n = len(data)
    ids = set(_find_id_like(data))
    scored = []
    for col in data.columns:
        s = data[col]
        n_unique = int(s.nunique(dropna=True))
        if n_unique <= 1:
            continue                                   # constants aren't targets
        if pd.api.types.is_datetime64_any_dtype(s):
            continue                                   # dates are rarely the target
        is_num = pd.api.types.is_numeric_dtype(s)
        is_bool = pd.api.types.is_bool_dtype(s)
        numeric_text = (not is_num and not is_bool
                        and _numeric_text_fraction(s) >= _NUMERIC_TEXT_MIN)
        numlike = is_num or numeric_text
        if col in ids and not numeric_text:
            continue                                   # IDs (a number-in-text isn't an ID)
        if not numlike and not is_bool and n_unique > 0.5 * n:
            continue                                   # free-text / near-unique text
        task = _detect_task(s, None)
        score = 0.0
        name = str(col).lower()
        if name in _COMMON_TARGET_NAMES or any(
                stem in name for stem in _TARGET_NAME_STEMS):
            score += 3
        if task == "classification":
            score += 2 if 2 <= n_unique <= 10 else (1 if n_unique <= 20 else 0)
        else:
            score += 2                                 # a continuous number is a fine target
            if numlike and 20 <= n_unique <= 0.5 * n:
                score += 1                             # a classic regression target shape
        if is_bool:
            score += 1                                 # clean yes/no
        score -= 2 * float(s.isna().mean())            # missing-heavy columns are worse
        scored.append((score, col, task, n_unique))
    scored.sort(key=lambda t: t[0], reverse=True)
    return [(col, task, n_unique) for _, col, task, n_unique in scored]


def _task_phrase(task: str) -> str:
    return "regression (predict a number)" if task == "regression" else "classification (sort into groups)"


def recommend(data: pd.DataFrame) -> None:
    """Suggest which column you might want to predict (your 'target'), best guess first.

    These are only guesses based on each column's type and how many distinct values it
    has - you know your data best. Example:  kami.recommend(df)
    """
    _require_dataframe(data, "kami.recommend")
    ranked = _rank_targets(data)
    _emit("Kami: Likely columns to predict (your 'target') - best guess first:")
    _emit("")
    if not ranked:
        _emit("   Hmm, nothing stood out as an obvious target. You can still pick any")
        _emit("   column yourself with kami.train(df, target='your_column').")
        return
    for i, (col, task, n_unique) in enumerate(ranked[:5], start=1):
        _emit(f"   {i}. {col}")
        _emit(f"        {_task_phrase(task)}   ({n_unique:,} distinct values)")
    top = ranked[0][0]
    _emit("")
    _emit(_wrap(
        f"These are guesses, not certainties - pick the one you actually care about. "
        f"To start: result = kami.train(df, target='{top}').",
        first="   ", indent="   ",
    ))


def report(data: pd.DataFrame) -> None:
    """A quick, high-level summary of a dataset - the 'what have I got here?' view.

    Complements kami.look(df) (column-by-column detail) and kami.audit(df) (data
    problems). Example:  kami.report(df)
    """
    _require_dataframe(data, "kami.report")
    n, m = len(data), data.shape[1]
    _emit("Kami: Dataset report")
    _emit("")
    _emit(f"   Size: {n:,} rows x {m} columns")

    # column-type breakdown (reusing the same plain-English labels as look())
    buckets = {"number": 0, "text": 0, "true/false": 0, "dates": 0, "categories": 0}
    for c in data.columns:
        t = _plain_dtype(data[c])
        key = "number" if t in ("whole numbers", "decimal numbers") else t
        buckets[key] = buckets.get(key, 0) + 1
    _emit("   Column types:")
    labels = {"number": "number", "text": "text", "true/false": "true/false",
              "dates": "date", "categories": "category"}
    for key, count in buckets.items():
        if count:
            _emit(f"      - {count} {labels[key]} column{'s' if count != 1 else ''}")

    # missing
    miss = data.isna().sum()
    miss = miss[miss > 0]
    if len(miss) == 0:
        _emit("   Missing values: none.")
    else:
        names = ", ".join(str(c) for c in miss.index[:6]) + (" ..." if len(miss) > 6 else "")
        _emit(f"   Missing values: {int(miss.sum()):,} total, in {len(miss)} column(s) ({names}).")

    # duplicates
    dups = int(data.duplicated().sum())
    _emit(f"   Duplicate rows: {dups:,}" + (f" ({_pct(dups, n)})" if dups else ""))

    # high-cardinality text
    text_cols = [c for c in _text_columns(data)
                 if not pd.api.types.is_datetime64_any_dtype(data[c]) and data[c].nunique() > 50]
    if text_cols:
        _emit(f"   High-cardinality text columns: {text_cols}")

    # id-like
    ids = _find_id_like(data)
    if ids:
        _emit(f"   Possible ID columns: {ids}")

    # target candidates (reuse the ranker)
    ranked = _rank_targets(data)
    if ranked:
        names = ", ".join(col for col, _, _ in ranked[:3])
        _emit(f"   Likely columns to predict: {names}")

    _emit("")
    _emit(_wrap(
        "Next: kami.recommend(df) ranks the targets in more detail, kami.audit(df) digs "
        "into data-quality problems, and kami.look(df) shows every column one by one.",
        first="   ", indent="   ",
    ))


# ======================================================================
# DATA CLEANING  -  kami.clean(df)
# Tidy up the common messes in a freshly-loaded dataset (text that's really
# numbers, dates stored as text, stray spaces, empty/duplicate rows), and
# explain every fix. Returns a cleaned copy:  df = kami.clean(df)
# ======================================================================

def clean(data: pd.DataFrame, explain: str = "guide") -> pd.DataFrame:
    """Tidy up the most common messes in a dataset, explaining each fix as it goes.

    Fixes: text columns that are really numbers, date columns stored as text,
    stray spaces, completely empty columns/rows, and exact duplicate rows.
    Nothing is changed silently - every fix is announced. Returns a cleaned COPY,
    so keep it:  df = kami.clean(df).  Example:  df = kami.clean(df)
    """
    _require_dataframe(data, "kami.clean")
    explain = _check_explain(explain)
    df = data.copy()
    start_shape = df.shape
    notes = []

    # 1. trim spaces from column names (don't rename - just tidy)
    trimmed = [str(c).strip() for c in df.columns]
    if list(trimmed) != list(df.columns):
        df.columns = trimmed
        notes.append("Trimmed extra spaces from the column names.")

    # 1b. make duplicate column names unique (else df[col] returns several columns)
    if df.columns.duplicated().any():
        seen, new = {}, []
        for c in df.columns:
            if c in seen:
                seen[c] += 1
                new.append(f"{c}_{seen[c]}")
            else:
                seen[c] = 1
                new.append(c)
        df.columns = new
        notes.append("Renamed duplicate column name(s) so each one is unique "
                     "(e.g. 'dup' -> 'dup_2').")

    # 1c. treat common placeholder text ('NA', 'null', '?', '-', ...) as blank
    null_changed = 0
    for col in list(df.columns):
        if df[col].dtype != object:
            continue
        is_nullish = df[col].map(
            lambda x: isinstance(x, str) and x.strip().lower() in _NULL_TOKENS)
        cnt = int(is_nullish.sum())
        if cnt:
            df[col] = df[col].mask(is_nullish, np.nan)
            null_changed += cnt
    if null_changed:
        notes.append(f"Treated {null_changed:,} placeholder value(s) like 'NA', 'null' "
                     f"and '?' as blank.")

    # 2. per text-like column: strip spaces, then try numbers, then try dates
    to_number, to_date = [], []
    for col in list(df.columns):
        s = df[col]
        if (pd.api.types.is_numeric_dtype(s) or pd.api.types.is_bool_dtype(s)
                or pd.api.types.is_datetime64_any_dtype(s)):
            continue
        df[col] = df[col].map(lambda x: x.strip() if isinstance(x, str) else x)
        non_null = int(df[col].notna().sum())
        if non_null == 0:
            continue
        # fast sampled check first; only run the heavier full strip on promising columns
        if _numeric_text_fraction(df[col]) >= _NUMERIC_TEXT_MIN:
            as_num = _coerce_numeric_text(df[col])
            if int(as_num.notna().sum()) >= _NUMERIC_TEXT_MIN * non_null:
                df[col] = as_num
                to_number.append(col)
                continue
        name = str(col).lower()
        hint = any(k in name for k in ("date", "time", "_at", "timestamp"))
        # cheap sampled check before parsing the whole column as dates
        sample = df[col].dropna()
        if len(sample) > 1000:
            sample = sample.sample(1000, random_state=0)
        sample_rate = float(_safe_to_datetime(sample).notna().mean()) if len(sample) else 0.0
        if hint or sample_rate >= 0.9:
            as_date = _safe_to_datetime(df[col])
            parsed = int(as_date.notna().sum())
            if parsed >= 0.9 * non_null and (hint or parsed == non_null):
                df[col] = as_date
                to_date.append(col)
    if to_number:
        notes.append(f"Converted {len(to_number)} text column(s) that were really numbers: {to_number}")
    if to_date:
        notes.append(f"Parsed {len(to_date)} column(s) that were dates stored as text: {to_date}")

    # 2b. turn infinities into blanks (they break models, scaling and charts)
    num_cols = list(df.select_dtypes(include=[np.number]).columns)
    if num_cols:
        inf_count = int(np.isinf(df[num_cols].to_numpy(dtype="float64")).sum())
        if inf_count:
            df[num_cols] = df[num_cols].replace([np.inf, -np.inf], np.nan)
            notes.append(f"Replaced {inf_count:,} infinite value(s) with blanks "
                         f"(infinity breaks most models and charts).")

    # 3. drop completely empty columns
    empty_cols = [c for c in df.columns if df[c].isna().all()]
    if empty_cols:
        df = df.drop(columns=empty_cols)
        notes.append(f"Dropped {len(empty_cols)} completely empty column(s): {empty_cols}")

    # 4. drop completely empty rows
    before = len(df)
    df = df.dropna(how="all")
    if before - len(df):
        notes.append(f"Dropped {before - len(df):,} completely empty row(s).")

    # 5. drop exact duplicate rows
    before = len(df)
    df = df.drop_duplicates().reset_index(drop=True)
    if before - len(df):
        notes.append(f"Removed {before - len(df):,} exact duplicate row(s).")

    _say("Kami is cleaning your data...", explain, 1)
    _say("", explain, 1)
    if not notes:
        _say("   Your data already looks tidy - nothing needed fixing. Nice.", explain, 1)
    else:
        for note in notes:
            _say(_wrap(f"- {note}", first="   ", indent="     "), explain, 1)
    _say("", explain, 1)
    _say(
        _wrap(
            f"Done. Your data went from {start_shape[0]:,} rows x {start_shape[1]} columns "
            f"to {df.shape[0]:,} rows x {df.shape[1]} columns.",
            first="   ", indent="   ",
        ),
        explain, 1,
    )
    _say("   Remember to keep the result:  df = kami.clean(df)", explain, 1)
    return df


# ======================================================================
# MODEL BAKEOFF  -  kami.bakeoff(df, target='...')
# Train several beginner-friendly models on the same data, rank them, name a
# winner, and explain why it likely won. Reuses train() for every candidate, so
# they all get identical preprocessing and the same train/test split.
# ======================================================================

_BAKEOFF_MODELS = {
    "regression": [
        "LinearRegression", "Ridge", "Lasso", "KNeighborsRegressor",
        "DecisionTreeRegressor", "RandomForestRegressor", "GradientBoostingRegressor",
    ],
    "classification": [
        "LogisticRegression", "GaussianNB", "KNeighborsClassifier",
        "DecisionTreeClassifier", "RandomForestClassifier", "GradientBoostingClassifier",
    ],
}


def _bakeoff_why(name: str) -> str:
    """A plain-English, honest guess at why a given model family tends to win."""
    n = name.lower()
    if "randomforest" in n or "gradientboosting" in n:
        return ("Ensemble tree models like this usually win because they pick up non-linear "
                "patterns and interactions between columns on their own, handle mixed "
                "numeric/text data well, and resist overfitting - all without tuning.")
    if "linear" in n or "ridge" in n or "lasso" in n or "logistic" in n:
        return ("A linear model winning often means the relationship in your data is close to "
                "a straight line - when the pattern is simple, simple models can beat complex "
                "ones.")
    if "kneighbors" in n:
        return ("K-Nearest-Neighbors winning suggests rows with similar feature values tend to "
                "have similar answers - a 'look at the closest examples' pattern.")
    if "decisiontree" in n:
        return ("A single decision tree winning suggests your data splits cleanly on a few key "
                "thresholds.")
    if "gaussiannb" in n or "naive" in n:
        return ("Naive Bayes winning can happen when the columns are fairly independent of one "
                "another.")
    return "It simply scored highest on the held-out test data."


def bakeoff(data: pd.DataFrame, target: str = None, explain: str = "guide",
            missing: str = "fill", ignore=None):
    """Train several beginner-friendly models on your data and rank them best-first.

    Names a winner and explains why it likely won, in plain English. The winning model
    is returned ready to use, so:  champ = kami.bakeoff(df, target='price'); champ.predict(...)
    ignore=['col1', 'col2'] leaves those columns out of every model.

    Example:  champ = kami.bakeoff(df, target='price')
    """
    _require_dataframe(data, "kami.bakeoff")
    if target is None:
        raise ValueError(
            "kami.bakeoff needs the column you want to predict:\n"
            "       champ = kami.bakeoff(df, target='the_column_you_want_to_predict')\n"
            "   Not sure which? Try kami.recommend(df)."
        )
    if target not in data.columns:
        raise ValueError(
            f"There is no column called '{target}'. Your columns are: {list(data.columns)}"
        )
    explain = _check_explain(explain)

    task = _detect_task(data[target], None)
    candidates = _BAKEOFF_MODELS[task]

    _say(
        _wrap(
            f"Kami: Bakeoff - training {len(candidates)} models to predict '{target}', then "
            "ranking them. This trains several models, so give it a moment..."
        ),
        explain, 1,
    )
    _say("", explain, 1)

    results, failures = [], []
    for name in candidates:
        try:
            r = train(data, target=target, model=name, explain="silent",
                      missing=missing, ignore=ignore)
            results.append((name, r.score, r))
        except Exception:
            failures.append(name)

    if not results:
        raise ValueError(
            "None of the models could be trained on this data. Run "
            "kami.audit(df, target='...') to look for problems first."
        )

    results.sort(key=lambda t: t[1], reverse=True)
    metric = "R2" if task == "regression" else "accuracy"

    pairs = []
    for i, (name, score, _) in enumerate(results, start=1):
        val = f"{score:.3f}" if task == "regression" else f"{score:.1%}"
        pairs.append((f"{i}. {name}", val))
    _show_scorecard(pairs, "guide", title=f"MODEL BAKEOFF (higher {metric} is better)")

    winner_name, winner_score, winner_result = results[0]
    win_val = f"{winner_score:.3f}" if task == "regression" else f"{winner_score:.1%}"
    _say("", explain, 1)
    _say(_wrap(f"Kami: Winner - {winner_name} ({metric} = {win_val})."), explain, 1)
    _say(_wrap(f"Why it likely won: {_bakeoff_why(winner_name)}", first="      ", indent="      "),
         explain, 1)

    # honest caveat when even the winner has no real signal
    if task == "classification":
        baseline = float(data[target].dropna().value_counts(normalize=True).max())
        if winner_score <= baseline + 0.01:
            _say(_wrap(
                f"Kami: HEADS UP - even the best model ({win_val}) barely beats just guessing "
                f"the most common answer ({baseline:.0%}). None of these found a strong pattern; "
                f"the useful signal may not be in your columns."), explain, 1)
    elif winner_score <= 0:
        _say(_wrap(
            f"Kami: HEADS UP - even the best model scored {win_val}, no better than guessing the "
            f"average. None of these found real signal in your columns."), explain, 1)

    if failures:
        _say(_wrap(f"(Couldn't train: {failures} - skipped those.)", first="   ", indent="   "),
             explain, 1)

    _say("", explain, 1)
    _say("   To use the winner, save it first (put 'champ =' in front), then:", explain, 1)
    _say(f"       champ = kami.bakeoff(df, target='{target}')", explain, 1)
    _say("       champ.predict(new_row)          # predict on new data", explain, 1)
    _say("       kami.plot_predictions(champ)    # or see how good it is", explain, 1)
    _say(_wrap(
        "If you ran kami.bakeoff(...) without 'champ =' in front, the winner was shown but "
        "not saved - run it again with 'champ =' so you can use it. (Scores come from one "
        "split, so very close results aren't a firm tie-breaker.)",
        first="   ", indent="   "), explain, 1)
    return winner_result