"""
Tests for the kami module (ablevlabs.kami).

A test file is just code that checks your library still does what it should.
Each test runs Kami on a small sample dataset and then `assert`s that the result
is sensible. If you ever change Kami and accidentally break something, these
tests turn red and tell you immediately.

Run them from the project root:
    pytest                       # runs every test in the project
    pytest tests/test_kami.py -v # runs just these, with names shown
"""

import matplotlib

matplotlib.use("Agg")  # render charts to memory/files, no screen needed

import numpy as np
import pandas as pd
import pytest

from ablevlabs import kami

# --- small reusable sample datasets -----------------------------------------


def _classification_df():
    """A tiny dataset with an ID column, a text column, and a yes/no target."""
    rng = np.random.default_rng(0)
    n = 200
    score = rng.normal(0, 1, n)
    return pd.DataFrame(
        {
            "user_id": range(n),  # ID -> should be dropped
            "feature_a": score,
            "plan": rng.choice(["Basic", "Pro"], n),  # text -> should be encoded
            "signed_up": np.where(score > 0, "Yes", "No"),  # yes/no -> classification
        }
    )


def _regression_df():
    """A tiny dataset whose target is a continuous number."""
    rng = np.random.default_rng(1)
    n = 200
    x = rng.normal(0, 1, n)
    return pd.DataFrame(
        {
            "x": x,
            "y": x * 3 + rng.normal(0, 0.1, n),  # numeric target -> regression
        }
    )


def _missing_df():
    """A regression dataset with missing values in one feature column."""
    rng = np.random.default_rng(2)
    n = 200
    x = rng.normal(0, 1, n)
    extra = rng.normal(0, 1, n)
    extra[:40] = np.nan  # 40 missing values in 'extra'
    return pd.DataFrame(
        {
            "x": x,
            "extra": extra,
            "y": x * 3 + rng.normal(0, 0.1, n),
        }
    )


# --- the tests --------------------------------------------------------------


def test_train_returns_result_with_expected_fields():
    result = kami.train(_classification_df(), target="signed_up", explain="silent")
    assert result.target == "signed_up"
    assert result.model_name == "RandomForestClassifier"
    assert 0.0 <= result.score <= 1.0  # accuracy is a proportion


def test_detects_classification():
    result = kami.train(_classification_df(), target="signed_up", explain="silent")
    assert result.task == "classification"


def test_detects_regression():
    result = kami.train(_regression_df(), target="y", explain="silent")
    assert result.task == "regression"
    assert result.rmse is not None  # regression reports an RMSE


def test_id_column_is_dropped():
    result = kami.train(_classification_df(), target="signed_up", explain="silent")
    assert not any("user_id" in str(col) for col in result.feature_columns)


def test_predict_returns_one_prediction_per_row():
    result = kami.train(_classification_df(), target="signed_up", explain="silent")
    new_rows = pd.DataFrame(
        {
            "user_id": [999, 1000],
            "feature_a": [2.5, -2.5],
            "plan": ["Pro", "Basic"],
        }
    )
    preds = result.predict(new_rows)
    assert len(preds) == 2


def test_raw_is_the_underlying_sklearn_model():
    from sklearn.ensemble import RandomForestClassifier

    result = kami.train(_classification_df(), target="signed_up", explain="silent")
    assert isinstance(result.raw, RandomForestClassifier)


def test_accepts_a_custom_model():
    from sklearn.linear_model import LogisticRegression

    result = kami.train(
        _classification_df(),
        target="signed_up",
        model=LogisticRegression(max_iter=1000),
        explain="silent",
    )
    assert result.model_name == "LogisticRegression"


def test_accepts_a_model_by_name():
    result = kami.train(
        _regression_df(), target="y", model="LinearRegression", explain="silent"
    )
    assert result.model_name == "LinearRegression"


def test_bad_target_raises_a_clear_error():
    with pytest.raises(ValueError):
        kami.train(_classification_df(), target="does_not_exist", explain="silent")


def test_bad_explain_raises_a_clear_error():
    with pytest.raises(ValueError):
        kami.train(_classification_df(), target="signed_up", explain="loud")


# --- the new missing-values behaviour ---------------------------------------


def test_missing_fills_by_default():
    # default missing="fill" -> Kami fills blanks and trains, no blocking
    result = kami.train(_missing_df(), target="y", explain="silent")
    assert result.score is not None


def test_missing_ask_stops_and_prompts():
    # missing="ask" is the opt-in that forces the user to choose
    with pytest.raises(ValueError, match="decide how to handle"):
        kami.train(_missing_df(), target="y", missing="ask", explain="silent")


def test_missing_fill_trains_successfully():
    result = kami.train(_missing_df(), target="y", missing="fill", explain="silent")
    assert result.task == "regression"


def test_missing_drop_trains_successfully():
    result = kami.train(_missing_df(), target="y", missing="drop", explain="silent")
    assert result.task == "regression"


def test_bad_missing_raises_a_clear_error():
    with pytest.raises(ValueError):
        kami.train(_missing_df(), target="y", missing="banana", explain="silent")


# --- Wave 1: the expanded model menu ----------------------------------------


def test_new_regression_models_by_name():
    """Every regression model in the menu should train when chosen by name."""
    for name in [
        "Ridge",
        "Lasso",
        "KNeighborsRegressor",
        "SVR",
        "DecisionTreeRegressor",
        "GradientBoostingRegressor",
        "MLPRegressor",
    ]:
        result = kami.train(_regression_df(), target="y", model=name, explain="silent")
        assert result.model_name == name


def test_new_classification_models_by_name():
    """Every classification model in the menu should train when chosen by name."""
    for name in [
        "GaussianNB",
        "KNeighborsClassifier",
        "SVC",
        "DecisionTreeClassifier",
        "GradientBoostingClassifier",
        "MLPClassifier",
    ]:
        result = kami.train(
            _classification_df(), target="signed_up", model=name, explain="silent"
        )
        assert result.model_name == name


def test_unknown_model_name_lists_valid_options():
    """A wrong model name should fail with the list of names the user can pick."""
    with pytest.raises(ValueError, match="Ridge"):
        kami.train(
            _regression_df(), target="y", model="NotARealModel", explain="silent"
        )


def test_info_runs_without_error():
    """kami.info() should print the getting-started guide without crashing."""
    kami.info()


# --- kami.load(): multi-format loading + honest errors ----------------------


def test_load_roundtrips_csv_and_json(tmp_path):
    """load() should read back what we write, for the always-available formats."""
    df = _regression_df()
    csv_path = tmp_path / "data.csv"
    json_path = tmp_path / "data.json"
    df.to_csv(csv_path, index=False)
    df.to_json(json_path, orient="records")
    assert kami.load(str(csv_path)).shape == df.shape
    assert kami.load(str(json_path)).shape == df.shape


def test_load_explains_sql_files():
    """A .sql file isn't a table; load() should say so clearly instead of crashing."""
    with pytest.raises(ValueError, match="database"):
        kami.load("dump.sql")


def test_load_rejects_unknown_extension():
    """An unsupported extension should list the formats that ARE supported."""
    with pytest.raises(ValueError, match="recognise"):
        kami.load("mystery.xyz")


def test_load_missing_file_is_friendly():
    """A missing file should raise FileNotFoundError with a readable message."""
    with pytest.raises(FileNotFoundError, match="couldn't find"):
        kami.load("definitely_not_here.csv")


def test_fully_empty_column_is_dropped(tmp_path):
    """A 100%-empty feature column can't be filled, so train() should drop it and run."""
    df = _regression_df()
    df["all_blank"] = np.nan
    result = kami.train(df, target="y", missing="fill", explain="silent")
    assert "all_blank" not in result.feature_columns


# --- look()'s per-column breakdown ------------------------------------------


def test_plain_dtype_descriptions():
    """Column types should map to plain-English words."""
    assert kami._plain_dtype(pd.Series([1, 2, 3])) == "whole numbers"
    assert kami._plain_dtype(pd.Series([1.5, 2.7])) == "decimal numbers"
    assert (
        kami._plain_dtype(pd.Series([2.0, 3.0])) == "whole numbers"
    )  # whole-valued floats
    assert kami._plain_dtype(pd.Series(["a", "b"])) == "text"
    assert kami._plain_dtype(pd.Series([True, False])) == "true/false"
    assert kami._plain_dtype(pd.to_datetime(["2024-01-01", "2024-02-01"])) == "dates"


def test_look_shows_a_line_per_column(capsys):
    """look() should print a profile line for every column."""
    df = _classification_df()
    kami.look(df)
    printed = capsys.readouterr().out
    for col in df.columns:
        assert col in printed
    assert "What each column holds" in printed


# --- kami.predict() convenience wrapper -------------------------------------


def test_kami_predict_matches_result_predict():
    """kami.predict(result, X) should equal result.predict(X)."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    new_rows = df.drop(columns=["y"]).head(5)
    import numpy as np

    assert np.allclose(kami.predict(result, new_rows), result.predict(new_rows))


def test_kami_predict_rejects_non_result():
    """Passing something that isn't a kami.train() result gives a clear error."""
    with pytest.raises(ValueError, match="kami.train"):
        kami.predict("not a result", _regression_df())


# --- predict() accepts friendly input shapes --------------------------------


def test_predict_accepts_a_dict():
    """A single new row can be passed as a plain dict."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    row = {c: df[c].iloc[0] for c in result.input_columns}
    preds = kami.predict(result, row)
    assert len(preds) == 1


def test_predict_accepts_list_of_dicts():
    """Several new rows can be passed as a list of dicts."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    rows = [{c: df[c].iloc[i] for c in result.input_columns} for i in range(3)]
    preds = kami.predict(result, rows)
    assert len(preds) == 3


def test_predict_loose_values_give_friendly_error():
    """Passing loose values (not a dict/DataFrame) explains the right format."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    with pytest.raises(ValueError, match="named columns"):
        result.predict([1, 2, 3])


def test_fill_values_recorded_when_filling():
    """result.fill_values reports exactly what Kami put into the blanks."""
    result = kami.train(_missing_df(), target="y", explain="silent")
    assert "extra" in result.fill_values
    # the missing column is numeric, so it should be filled with a number (the median)
    assert isinstance(result.fill_values["extra"], float)


def test_fill_values_empty_when_nothing_missing():
    """Clean data invents nothing, so fill_values is empty."""
    result = kami.train(_regression_df(), target="y", explain="silent")
    assert result.fill_values == {}


def test_fill_values_empty_when_dropping():
    """missing='drop' removes rows rather than inventing values, so nothing is recorded."""
    result = kami.train(_missing_df(), target="y", missing="drop", explain="silent")
    assert result.fill_values == {}


def test_example_row_is_an_editable_template():
    """example_row() returns a complete, editable row that predict accepts."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    template = result.example_row()
    # it covers exactly the input columns (no target)
    assert set(template) == set(result.input_columns)
    assert "y" not in template
    # editing it and predicting works...
    template[result.input_columns[0]] = 123.0
    preds = kami.predict(result, template)
    assert len(preds) == 1
    # ...and editing the copy does not corrupt the stored template
    assert result.example_row()[result.input_columns[0]] != 123.0


def test_result_exposes_input_columns():
    """The result should know the original input columns the user types for predict."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    assert "y" not in result.input_columns
    assert len(result.input_columns) >= 1


# --- diversity: Kami must work on MANY kinds of data, not just one ----------
# These run the whole flow (look -> train -> predict) against deliberately
# different datasets, so dataset-specific assumptions get caught automatically
# instead of by hand-testing a new file each time.


def _div_regression_signal():
    rng = np.random.default_rng(11)
    n = 250
    a = rng.normal(0, 1, n)
    b = rng.uniform(0, 100, n)
    return pd.DataFrame(
        {"a": a, "b": b, "price": 5 * a + 0.3 * b + rng.normal(0, 1, n)}
    )


def _div_mixed_classification():
    rng = np.random.default_rng(12)
    n = 250
    age = rng.integers(18, 80, n)
    region = rng.choice(["North", "South", "East", "West"], n)  # text
    premium = rng.random(n) > 0.5  # bool
    churn = np.where((age > 60) | (region == "North"), "yes", "no")
    return pd.DataFrame(
        {"age": age, "region": region, "premium": premium, "churn": churn}
    )


def _div_all_text():
    rng = np.random.default_rng(13)
    n = 250
    color = rng.choice(["red", "green", "blue"], n)
    shape = rng.choice(["circle", "square"], n)
    return pd.DataFrame(
        {"color": color, "shape": shape, "label": np.where(color == "red", "A", "B")}
    )


def _div_with_dates():
    rng = np.random.default_rng(14)
    n = 250
    dates = pd.to_datetime(
        ["2024-01-01", "2024-02-01", "2024-03-01", "2024-04-01", "2024-05-01"]
    )
    return pd.DataFrame(
        {
            "when": rng.choice(dates, n),  # datetime column
            "amount": rng.uniform(0, 500, n),
            "is_high": (rng.uniform(0, 500, n) > 250).astype(int),
        }
    )


def _div_no_signal():
    rng = np.random.default_rng(15)
    n = 250
    return pd.DataFrame(
        {
            "x1": rng.normal(0, 1, n),
            "x2": rng.uniform(0, 1, n),
            "y": rng.normal(0, 1, n),
        }
    )  # target unrelated to x1/x2


@pytest.mark.parametrize(
    "builder,target,task",
    [
        (_div_regression_signal, "price", "regression"),
        (_div_mixed_classification, "churn", "classification"),
        (_div_all_text, "label", "classification"),
        (_div_with_dates, "is_high", "classification"),
        (_div_no_signal, "y", "regression"),
    ],
)
def test_runs_end_to_end_on_diverse_data(builder, target, task):
    """look -> train -> predict must all work on any of these shapes."""
    df = builder()
    kami.look(df, target=target)  # must not raise
    result = kami.train(df, target=target, missing="fill", explain="silent")
    assert result.task == task
    assert isinstance(result.score, float)
    row = {c: df[c].iloc[0] for c in result.input_columns}
    assert len(kami.predict(result, row)) == 1


def test_messages_never_assume_a_specific_dataset(capsys):
    """If a test-context word ever leaks into Kami's output, this fails."""
    df = _div_no_signal()
    kami.look(df, target="y")
    kami.train(df, target="y", missing="fill", explain="teacher")
    out = capsys.readouterr().out.lower()
    for banned in ("lottery", "powerball", "powerplay", "dragon ball", "spacex"):
        assert banned not in out, f"'{banned}' leaked into Kami's output"


def test_small_diverse_dataset_warns_and_runs(capsys):
    df = _div_regression_signal().head(20)
    result = kami.train(df, target="price", missing="fill", explain="guide")
    assert result.score is not None
    assert "small" in capsys.readouterr().out.lower()  # small-data warning fired


def test_missing_handled_across_policies():
    df = _div_regression_signal()
    df.loc[df.sample(30, random_state=0).index, "a"] = np.nan
    assert (
        kami.train(df, target="price", missing="fill", explain="silent").score
        is not None
    )
    assert (
        kami.train(df, target="price", missing="drop", explain="silent").score
        is not None
    )


def test_predict_on_a_row_with_a_blank_is_quiet(recwarn):
    """Predicting on a real row that has a blank cell must not emit noisy warnings."""
    df = _div_regression_signal()
    result = kami.train(df, target="price", explain="silent")
    row = {c: df[c].iloc[0] for c in result.input_columns}
    row[result.input_columns[0]] = float(
        "nan"
    )  # a blank, like evolves_from on a base form
    preds = kami.predict(result, row)
    assert len(preds) == 1
    assert not any("empty slice" in str(w.message).lower() for w in recwarn.list)


def test_train_with_no_arguments_gives_a_friendly_error():
    """kami.train() with nothing should explain what to do, not raise a raw TypeError."""
    with pytest.raises(ValueError, match="kami.look"):
        kami.train()


def test_train_with_only_data_gives_a_friendly_error():
    """Forgetting the target should be explained, not crash cryptically."""
    df = _regression_df()
    with pytest.raises(ValueError, match="predict"):
        kami.train(df)


def test_train_with_non_dataframe_gives_a_friendly_error():
    """Passing something that isn't a table should be caught with a clear message."""
    with pytest.raises(ValueError, match="DataFrame"):
        kami.train("not a dataframe", target="y")


def test_load_points_to_look(capsys, tmp_path):
    """After loading, Kami should tell the user the next step is kami.look(df)."""
    path = tmp_path / "mini.csv"
    _regression_df().to_csv(path, index=False)
    kami.load(str(path))
    out = capsys.readouterr().out
    assert "kami.look(df)" in out


def test_look_without_target_guides_to_target_and_train(capsys):
    """look(df) with no target must point the user to look-with-target AND to train."""
    kami.look(_regression_df())
    out = capsys.readouterr().out
    assert "kami.look(df, target=" in out
    assert "result = kami.train(df, target=" in out


def test_no_output_line_runs_off_the_screen(capsys):
    """No single line of Kami's narration should be absurdly long (the HEADS-UP bug)."""
    df = _div_no_signal()  # triggers the long HEADS-UP message
    kami.train(df, target="y", explain="teacher")
    lines = capsys.readouterr().out.split("\n")
    longest = max(lines, key=len)
    assert len(longest) <= 100, f"line too long ({len(longest)}): {longest!r}"


def test_training_results_shown_as_a_highlighted_block(capsys):
    """The score should appear in its own labelled block, not buried in prose."""
    df = _div_regression_signal()
    kami.train(df, target="price", explain="guide")
    out = capsys.readouterr().out
    assert "TRAINING RESULTS" in out
    assert "R2 score" in out


def test_training_ends_with_next_steps(capsys):
    """After predicting, the user should be given a direction to go next."""
    df = _div_regression_signal()
    kami.train(df, target="price", explain="guide")
    out = capsys.readouterr().out
    assert "What you can try next" in out
    assert "kami.models(" in out


# --- example_row template printing ------------------------------------------


def test_example_row_prints_the_template(capsys):
    """Calling example_row() should SHOW the row (so the user can see what to edit)."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    capsys.readouterr()  # clear training output
    result.example_row()
    out = capsys.readouterr().out
    assert "editable row template" in out
    assert "new_row[" in out  # shows the edit syntax for each column


def test_example_row_can_stay_silent(capsys):
    """example_row(show=False) returns the dict without printing."""
    df = _regression_df()
    result = kami.train(df, target="y", explain="silent")
    capsys.readouterr()
    row = result.example_row(show=False)
    assert capsys.readouterr().out == ""
    assert set(row) == set(result.input_columns)


# --- charts ------------------------------------------------------------------


def test_data_charts_save_files(tmp_path):
    """The four exploration charts should each write a non-empty image file."""
    df_num = _div_regression_signal()  # numeric: a, b, price
    df_cat = _div_mixed_classification()  # categorical: region; numeric: age
    kami.scatter(df_num, x="a", y="price", save=str(tmp_path / "s.png"))
    kami.hist(df_num, "a", save=str(tmp_path / "h.png"))
    kami.bar(df_cat, x="region", save=str(tmp_path / "b.png"))
    kami.bar(df_cat, x="region", y="age", save=str(tmp_path / "bm.png"))
    for name in ("s.png", "h.png", "b.png", "bm.png"):
        f = tmp_path / name
        assert f.exists() and f.stat().st_size > 0


def test_plot_predictions_regression_saves_file(tmp_path):
    df = _div_regression_signal()
    result = kami.train(df, target="price", explain="silent")
    out = tmp_path / "pred.png"
    kami.plot_predictions(result, save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_plot_predictions_classification_saves_file(tmp_path):
    df = _div_mixed_classification()
    result = kami.train(df, target="churn", explain="silent")
    out = tmp_path / "cm.png"
    kami.plot_predictions(result, save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_compare_models_saves_file(tmp_path):
    df = _div_regression_signal()
    a = kami.train(df, target="price", model="RandomForestRegressor", explain="silent")
    b = kami.train(df, target="price", model="Ridge", explain="silent")
    out = tmp_path / "cmp.png"
    kami.compare({"forest": a, "ridge": b}, save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_chart_with_unknown_column_errors():
    df = _div_regression_signal()
    with pytest.raises(ValueError, match="aren't in your data"):
        kami.scatter(df, x="does_not_exist", y="price")


def test_plot_predictions_needs_a_result():
    with pytest.raises(ValueError, match="kami.train"):
        kami.plot_predictions("not a result")


def test_compare_mixed_tasks_errors():
    """Comparing a regression model with a classification model is not meaningful."""
    reg = kami.train(_div_regression_signal(), target="price", explain="silent")
    clf = kami.train(_div_mixed_classification(), target="churn", explain="silent")
    with pytest.raises(ValueError, match="comparable"):
        kami.compare([reg, clf])


# --- data-quality audit ------------------------------------------------------


def _problem_df():
    """A dataset with one of each planted problem, for the audit to find."""
    rng = np.random.default_rng(7)
    n = 120
    sqft = rng.uniform(800, 3000, n)
    price = sqft * 200 + rng.normal(0, 12000, n)
    df = pd.DataFrame(
        {
            "house_id": [f"H{i:04d}" for i in range(n)],  # ID-like + high cardinality
            "sqft": sqft,
            "price_k": (price / 1000),  # ~perfect corr with price (leak)
            "country": ["USA"] * n,  # constant
            "price": price,
        }
    )
    df.loc[rng.choice(n, 15, replace=False), "sqft"] = np.nan  # missing
    return pd.concat([df, df.iloc[:5]], ignore_index=True)  # duplicates


def test_audit_flags_each_problem(capsys):
    kami.audit(_problem_df(), target="price")
    out = capsys.readouterr().out
    assert "MISSING VALUES" in out and "sqft" in out
    assert "DUPLICATE ROWS" in out
    assert "HIGH-CARDINALITY" in out
    assert "CONSTANT" in out and "country" in out
    assert "ID-LIKE" in out and "house_id" in out
    assert "POSSIBLE LEAKAGE" in out and "price_k" in out
    assert "SUMMARY" in out


def test_audit_clean_data_reports_no_problems(capsys):
    kami.audit(_div_regression_signal(), target="price")
    out = capsys.readouterr().out
    assert "no major data-quality problems" in out


def test_audit_unknown_target_errors():
    with pytest.raises(ValueError, match="no column called"):
        kami.audit(_div_regression_signal(), target="not_here")


def test_audit_lines_wrap(capsys):
    kami.audit(_problem_df(), target="price")
    longest = max(capsys.readouterr().out.split("\n"), key=len)
    assert len(longest) <= 100, f"audit line too long ({len(longest)}): {longest!r}"


# --- feature importance + extra charts --------------------------------------


def test_feature_importance_tree_saves_file(tmp_path):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    out = tmp_path / "fi.png"
    kami.feature_importance(result, save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_feature_importance_linear_saves_file(tmp_path):
    result = kami.train(
        _div_regression_signal(), target="price", model="Ridge", explain="silent"
    )
    out = tmp_path / "fil.png"
    kami.feature_importance(result, save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_feature_importance_unsupported_model_errors():
    result = kami.train(
        _div_regression_signal(),
        target="price",
        model="KNeighborsRegressor",
        explain="silent",
    )
    with pytest.raises(ValueError, match="doesn't expose feature importances"):
        kami.feature_importance(result)


def test_heatmap_saves_file(tmp_path):
    out = tmp_path / "heat.png"
    kami.heatmap(_div_regression_signal(), save=str(out))
    assert out.exists() and out.stat().st_size > 0


def test_heatmap_needs_two_numeric_columns():
    df = pd.DataFrame({"only": [1, 2, 3], "text": ["a", "b", "c"]})
    with pytest.raises(ValueError, match="two numeric"):
        kami.heatmap(df)


def test_pie_saves_file(tmp_path):
    out = tmp_path / "pie.png"
    kami.pie(_div_mixed_classification(), "region", save=str(out))
    assert out.exists() and out.stat().st_size > 0


# --- the "df got overwritten to None" recovery path -------------------------


def test_none_data_in_audit_gives_recovery_message():
    """If df is None (commonly from df = kami.look(df)), say so and how to fix it."""
    with pytest.raises(ValueError, match="empty"):
        kami.audit(None, target="price")


def test_none_data_in_train_explains_the_overwrite_trap():
    with pytest.raises(ValueError, match="overwritten"):
        kami.train(None, target="price")


def test_none_data_in_look_is_caught():
    with pytest.raises(ValueError, match="empty"):
        kami.look(None)


def test_none_data_message_points_to_reload():
    """The recovery message must tell the user to reload with kami.load."""
    try:
        kami.train(None, target="x")
    except ValueError as e:
        assert "kami.load(" in str(e)


# --- score interpretation (verdict) -----------------------------------------


def test_interpret_score_regression_bands():
    assert kami._interpret_score("regression", 0.95)[0] == "Excellent"
    assert kami._interpret_score("regression", 0.80)[0] == "Strong"
    assert kami._interpret_score("regression", 0.60)[0] == "Moderate"
    assert kami._interpret_score("regression", 0.30)[0] == "Weak"
    assert kami._interpret_score("regression", -0.1)[0] == "No signal"


def test_interpret_score_classification_uses_baseline():
    # 93.7% accuracy is WEAK when the baseline is already 93.2%
    assert kami._interpret_score("classification", 0.937, 0.932)[0] == "Weak"
    # but strong when it clears a low baseline by a lot
    assert kami._interpret_score("classification", 0.92, 0.50)[0] == "Excellent"


def test_scorecard_includes_a_verdict(capsys):
    kami.train(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "Verdict" in out


# --- recommend + report ------------------------------------------------------


def test_recommend_ranks_a_sensible_target(capsys):
    kami.recommend(_div_regression_signal())  # columns a, b, price
    out = capsys.readouterr().out
    assert "price" in out
    assert "kami.train(df, target=" in out


def test_rank_targets_skips_id_columns():
    df = pd.DataFrame(
        {
            "customer_id": range(200),  # ID-like -> not a target
            "amount": np.random.default_rng(0).normal(50, 5, 200),
        }
    )
    ranked = [col for col, _, _ in kami._rank_targets(df)]
    assert "customer_id" not in ranked
    assert "amount" in ranked


def test_report_summarizes_the_dataset(capsys):
    kami.report(_div_mixed_classification())  # age, region, premium, churn
    out = capsys.readouterr().out
    assert "Dataset report" in out
    assert "Size:" in out
    assert "Duplicate rows" in out
    assert "Likely columns to predict" in out


def test_recommend_and_report_reject_none_data():
    with pytest.raises(ValueError, match="empty"):
        kami.recommend(None)
    with pytest.raises(ValueError, match="empty"):
        kami.report(None)


# --- highlighted prediction output ------------------------------------------


def test_prediction_is_shown_in_a_highlighted_block(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    capsys.readouterr()
    kami.predict(result, {"a": 1.0, "b": 50.0})
    out = capsys.readouterr().out
    assert "PREDICTION" in out
    assert "Predicted price" in out


def test_prediction_show_false_is_silent(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    capsys.readouterr()
    preds = kami.predict(result, {"a": 1.0, "b": 50.0}, show=False)
    assert capsys.readouterr().out == ""
    assert len(preds) == 1


def test_classification_prediction_shows_confidence(capsys):
    result = kami.train(_div_mixed_classification(), target="churn", explain="silent")
    capsys.readouterr()
    kami.predict(result, {"age": 70, "region": "North", "premium": True})
    out = capsys.readouterr().out
    assert "PREDICTION" in out
    assert "Confidence" in out


# --- data cleaning -----------------------------------------------------------


def _messy_df():
    return pd.DataFrame(
        {
            "id": ["7129300520", "6414100192", "5631500400", "7129300520"],
            "date": [
                "20141013T000000",
                "20141209T000000",
                "20150225T000000",
                "20141013T000000",
            ],
            "price": [
                "221900",
                "538000",
                "1.225e+006",
                "221900",
            ],  # incl. scientific notation
            "note": ["  hi  ", " there", "x", "  hi  "],
            "all_empty": [np.nan, np.nan, np.nan, np.nan],
        }
    )


def test_clean_converts_text_numbers_and_dates(capsys):
    out_df = kami.clean(_messy_df())
    assert pd.api.types.is_numeric_dtype(out_df["price"])
    assert out_df["price"].iloc[2] == 1225000.0  # sci-notation parsed
    assert pd.api.types.is_datetime64_any_dtype(out_df["date"])
    printed = capsys.readouterr().out
    assert "Converted" in printed and "dates" in printed


def test_clean_removes_duplicates_and_empty_columns():
    out_df = kami.clean(_messy_df())
    assert "all_empty" not in out_df.columns  # empty column dropped
    assert len(out_df) == 3  # one exact duplicate removed


def test_clean_already_clean_says_so(capsys):
    kami.clean(pd.DataFrame({"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]}))
    assert "already looks tidy" in capsys.readouterr().out


def test_clean_output_trains_successfully():
    """The whole point: a cleaned messy dataset should train without complaint."""
    rng = np.random.default_rng(3)
    n = 120
    sqft = rng.uniform(800, 3000, n)
    price = sqft * 200 + rng.normal(0, 12000, n)
    messy = pd.DataFrame(
        {
            "sqft": [str(int(v)) for v in sqft],  # numbers stored as text
            "waterfront": [str(int(b)) for b in (rng.random(n) > 0.5)],
            "price": [str(int(p)) for p in price],  # target stored as text
        }
    )
    cleaned = kami.clean(messy, explain="silent")
    assert pd.api.types.is_numeric_dtype(cleaned["price"])
    result = kami.train(cleaned, target="price", explain="silent")
    assert result.score is not None


def test_clean_rejects_none():
    with pytest.raises(ValueError, match="empty"):
        kami.clean(None)


# --- automated model comparison (bakeoff) -----------------------------------


def test_bakeoff_regression_ranks_and_returns_a_winner(capsys):
    best = kami.bakeoff(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "MODEL BAKEOFF" in out
    assert "Winner" in out
    assert isinstance(best, kami.KamiResult)
    assert best.task == "regression"
    assert best.score is not None


def test_bakeoff_classification_ranks_and_returns_a_winner(capsys):
    best = kami.bakeoff(_div_mixed_classification(), target="churn", explain="guide")
    out = capsys.readouterr().out
    assert "MODEL BAKEOFF" in out
    assert "Winner" in out
    assert isinstance(best, kami.KamiResult)
    assert best.task == "classification"


def test_bakeoff_winner_is_usable_for_prediction():
    best = kami.bakeoff(_div_regression_signal(), target="price", explain="silent")
    preds = best.predict({"a": 1.0, "b": 50.0}, show=False)
    assert len(preds) == 1


def test_bakeoff_winner_has_the_top_score():
    """The returned winner should be the highest-scoring candidate."""
    df = _div_regression_signal()
    best = kami.bakeoff(df, target="price", explain="silent")
    # a plain LinearRegression should not beat the declared winner
    lin = kami.train(df, target="price", model="LinearRegression", explain="silent")
    assert best.score >= lin.score - 1e-9


def test_bakeoff_needs_a_target():
    with pytest.raises(ValueError, match="column you want to predict"):
        kami.bakeoff(_div_regression_signal())


def test_bakeoff_unknown_target_errors():
    with pytest.raises(ValueError, match="no column called"):
        kami.bakeoff(_div_regression_signal(), target="nope")


def test_bakeoff_rejects_none():
    with pytest.raises(ValueError, match="empty"):
        kami.bakeoff(None, target="price")


# --- bakeoff: the full metric table, not just one number --------------------


def _div_imbalanced_classification():
    """Lopsided classes: ~92% 'no'. Accuracy flatters a model that never says 'yes'."""
    rng = np.random.default_rng(21)
    n = 400
    score = rng.normal(0, 1, n)
    noise = rng.normal(0, 1, n)
    fraud = np.where(score > 1.75, "yes", "no")
    return pd.DataFrame({"score": score, "noise": noise, "fraud": fraud})


def test_bakeoff_classification_shows_precision_recall_f1(capsys):
    kami.bakeoff(_div_mixed_classification(), target="churn", explain="guide")
    out = capsys.readouterr().out
    assert "Precision" in out
    assert "Recall" in out
    assert "F1" in out


def test_bakeoff_regression_shows_r2_mae_rmse(capsys):
    kami.bakeoff(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "R2" in out
    assert "MAE" in out
    assert "RMSE" in out


def test_extra_metrics_works_with_text_labels():
    """precision/recall/f1 must survive 'yes'/'no' labels, not just 0/1."""
    r = kami.train(_div_mixed_classification(), target="churn", explain="silent")
    mets = kami._extra_metrics(r)
    for key in ("accuracy", "precision", "recall", "f1"):
        assert key in mets, f"{key} missing for text labels"
        assert 0.0 <= mets[key] <= 1.0


def test_extra_metrics_includes_roc_auc_when_probabilities_exist():
    r = kami.train(
        _div_mixed_classification(),
        target="churn",
        model="RandomForestClassifier",
        explain="silent",
    )
    mets = kami._extra_metrics(r)
    assert "roc_auc" in mets
    assert 0.0 <= mets["roc_auc"] <= 1.0


def test_extra_metrics_regression_keys():
    r = kami.train(_div_regression_signal(), target="price", explain="silent")
    mets = kami._extra_metrics(r)
    assert set(mets) == {"r2", "mae", "rmse"}
    assert mets["rmse"] >= mets["mae"] - 1e-9  # RMSE can never be below MAE


def test_bakeoff_auto_switches_to_f1_when_classes_are_lopsided(capsys):
    kami.bakeoff(_div_imbalanced_classification(), target="fraud", explain="guide")
    out = capsys.readouterr().out
    assert "ranked by f1" in out
    # kami word-wraps its prose, so a phrase can straddle a line break. Collapse
    # whitespace before matching, or the assertion breaks on wrapping alone.
    flat = " ".join(out.split())
    assert "would flatter" in flat  # it explains WHY it switched


def test_bakeoff_rank_by_choice_is_respected(capsys):
    kami.bakeoff(
        _div_mixed_classification(), target="churn", rank_by="recall", explain="guide"
    )
    out = capsys.readouterr().out
    assert "ranked by recall" in out


def test_bakeoff_rank_by_winner_really_is_top_on_that_metric():
    df = _div_mixed_classification()
    best = kami.bakeoff(df, target="churn", rank_by="f1", explain="silent")
    winner_f1 = kami._extra_metrics(best)["f1"]
    for name in ("LogisticRegression", "GaussianNB", "DecisionTreeClassifier"):
        other = kami.train(df, target="churn", model=name, explain="silent")
        assert winner_f1 >= kami._extra_metrics(other)["f1"] - 1e-9


def test_bakeoff_lower_is_better_metric_ranks_ascending():
    """RMSE is a loss: the winner must have the SMALLEST value, not the largest."""
    df = _div_regression_signal()
    best = kami.bakeoff(df, target="price", rank_by="rmse", explain="silent")
    winner_rmse = kami._extra_metrics(best)["rmse"]
    for name in ("LinearRegression", "DecisionTreeRegressor"):
        other = kami.train(df, target="price", model=name, explain="silent")
        assert winner_rmse <= kami._extra_metrics(other)["rmse"] + 1e-9


def test_bakeoff_bad_rank_by_errors():
    with pytest.raises(ValueError, match="isn't a"):
        kami.bakeoff(_div_regression_signal(), target="price", rank_by="accuracy")


def test_bakeoff_unknown_model_errors():
    with pytest.raises(ValueError, match="doesn't know"):
        kami.bakeoff(_div_regression_signal(), target="price", models=["NoSuchModel"])


def test_bakeoff_can_race_a_chosen_shortlist(capsys):
    kami.bakeoff(
        _div_regression_signal(),
        target="price",
        models=["LinearRegression", "Ridge"],
        explain="guide",
    )
    out = capsys.readouterr().out
    assert "LinearRegression" in out
    assert "Ridge" in out
    assert "RandomForestRegressor" not in out


def test_bakeoff_medals_can_be_turned_off(capsys):
    kami.bakeoff(
        _div_regression_signal(), target="price", medals=False, explain="guide"
    )
    out = capsys.readouterr().out
    assert "1st" not in out
    assert "MODEL BAKEOFF" in out  # the table still prints


def test_bakeoff_silent_prints_nothing(capsys):
    kami.bakeoff(_div_regression_signal(), target="price", explain="silent")
    assert capsys.readouterr().out.strip() == ""


def test_bakeoff_teacher_explains_each_metric(capsys):
    kami.bakeoff(_div_mixed_classification(), target="churn", explain="teacher")
    out = capsys.readouterr().out
    assert "how often it was right overall" in out


# --- preprocessing consolidation (train/predict no longer drift) ------------


def test_training_stores_imputation_for_every_input_column():
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    assert set(result._impute) == set(result.input_columns)


def test_predict_fills_blanks_with_training_values_not_new_data():
    """A blank in a new row must be filled with the TRAINING median, not recomputed."""
    rng = np.random.default_rng(0)
    n = 200
    sqft = rng.uniform(800, 3000, n)
    df = pd.DataFrame({"sqft": sqft, "price": sqft * 200 + rng.normal(0, 5000, n)})
    result = kami.train(df, target="price", explain="silent")
    blank = float(result.predict({"sqft": float("nan")}, show=False)[0])
    explicit = float(
        result.predict({"sqft": float(df["sqft"].median())}, show=False)[0]
    )
    assert abs(blank - explicit) < 1e-6  # identical -> no train/predict drift


def test_predict_still_works_with_text_columns_after_refactor():
    """End-to-end predict with encoded text columns should be unchanged."""
    result = kami.train(_div_mixed_classification(), target="churn", explain="silent")
    preds = result.predict({"age": 70, "region": "North", "premium": True}, show=False)
    assert len(preds) == 1


# --- cross-validation, summary(), and wrapped narration ---------------------


def test_cross_validation_reports_folds_and_average():
    df = _div_regression_signal()
    result = kami.train(df, target="price", validate="cross", explain="silent")
    assert result.validation == "cross"
    assert result.n_folds == 5
    assert result.score is not None


def test_cross_validation_output_shows_each_fold(capsys):
    kami.train(
        _div_regression_signal(), target="price", validate="cross", explain="guide"
    )
    out = capsys.readouterr().out
    assert "cross-validation" in out
    assert "SCORE ON EACH FOLD" in out
    assert "Fold 1" in out


def test_holdout_is_still_the_default():
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    assert result.validation == "holdout"
    assert result.n_folds is None


def test_bad_validate_value_is_rejected():
    with pytest.raises(ValueError, match="validate must be"):
        kami.train(_div_regression_signal(), target="price", validate="kfold")


def test_cross_validated_model_can_predict():
    result = kami.train(
        _div_regression_signal(), target="price", validate="cross", explain="silent"
    )
    assert len(result.predict({"a": 1.0, "b": 50.0}, show=False)) == 1


def test_summary_reports_how_the_model_was_built(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    result.summary()
    out = capsys.readouterr().out
    assert "MODEL SUMMARY" in out
    assert "price" in out
    assert "Validation" in out
    assert "Random state" in out


def test_encoding_explanation_lines_are_wrapped(capsys):
    """The one-hot encoding teacher note must not run off the screen."""
    kami.train(_div_mixed_classification(), target="churn", explain="teacher")
    out = capsys.readouterr().out
    for line in out.split("\n"):
        if "One-hot encoding" in line or "Models do maths" in line:
            assert len(line) <= 100, f"unwrapped prose line: {line!r}"


# --- model lessons (kami.learn) ---------------------------------------------


def test_learn_menu_lists_models(capsys):
    kami.learn()
    out = capsys.readouterr().out
    assert "Random Forest" in out
    assert "Logistic Regression" in out
    assert "kami.learn(" in out


def test_learn_specific_model_shows_sections(capsys):
    kami.learn("RandomForest")
    out = capsys.readouterr().out
    assert "KAMI ACADEMY" in out
    assert "THE IDEA" in out
    assert "HOW IT WORKS" in out
    assert "MAYBE NOT WHEN" in out
    assert "TRY IT" in out


def test_learn_accepts_aliases_and_sklearn_names(capsys):
    kami.learn("KNeighborsClassifier")
    assert "nearest-neighbors" in capsys.readouterr().out.lower()
    kami.learn("rf")
    assert "random forest" in capsys.readouterr().out.lower()


def test_learn_unknown_model_errors():
    with pytest.raises(ValueError, match="don't have a lesson"):
        kami.learn("xgboost")


# --- tabular foundation models (TabPFN) : explainer lesson ------------------


def test_tabpfn_lesson_is_in_the_menu(capsys):
    kami.learn()
    out = capsys.readouterr().out
    assert "TabPFN" in out


def test_tabpfn_lesson_shows_the_usual_sections(capsys):
    kami.learn("TabPFN")
    out = capsys.readouterr().out
    assert "KAMI ACADEMY" in out
    assert "THE IDEA" in out
    assert "HOW IT WORKS" in out
    assert "GOOD FIT WHEN" in out
    assert "MAYBE NOT WHEN" in out


def test_tabpfn_lesson_is_honest_about_not_shipping_it(capsys):
    kami.learn("TabPFN")
    out = capsys.readouterr().out
    flat = " ".join(out.split())
    # it must not pretend to be a kami model
    assert "WHERE TO START" in out
    assert "TRY IT" not in out
    assert "WORTH KNOWING" in out
    assert "pip install tabpfn" in flat


def test_tabpfn_lesson_accepts_aliases(capsys):
    for alias in ("tabfm", "pfn", "foundation model", "TabICL"):
        kami.learn(alias)
        assert "TabPFN" in capsys.readouterr().out, alias


# --- histogram boosting: the fast booster for big tables --------------------


def test_hist_boosting_is_a_known_model_for_both_tasks():
    assert "HistGradientBoostingRegressor" in kami._MODELS_BY_NAME["regression"]
    assert "HistGradientBoostingClassifier" in kami._MODELS_BY_NAME["classification"]


def test_models_menu_lists_hist_boosting(capsys):
    kami.models("regression")
    assert "HistGradientBoostingRegressor" in capsys.readouterr().out


def test_hist_boosting_trains_end_to_end():
    r = kami.train(
        _div_regression_signal(),
        target="price",
        model="HistGradientBoostingRegressor",
        explain="silent",
    )
    assert r.score is not None
    assert r.model_name


def test_hist_boosting_lesson_maps_to_the_boosting_lesson(capsys):
    kami.learn("HistGradientBoostingRegressor")
    assert "Gradient Boosting" in capsys.readouterr().out


def test_small_tables_keep_the_plain_booster():
    """Below the size threshold there's nothing to gain, so don't swap."""
    models, note = kami._pick_boosting(kami._BAKEOFF_MODELS["regression"], 500)
    assert "GradientBoostingRegressor" in models
    assert "HistGradientBoostingRegressor" not in models
    assert note is None


def test_big_tables_swap_in_the_fast_booster():
    models, note = kami._pick_boosting(
        kami._BAKEOFF_MODELS["classification"], kami._BIG_TABLE_ROWS + 1
    )
    assert "HistGradientBoostingClassifier" in models
    assert "GradientBoostingClassifier" not in models
    assert note and "quicker" in note  # and it explains itself


def test_boosting_swap_keeps_every_other_model():
    base = kami._BAKEOFF_MODELS["regression"]
    models, _ = kami._pick_boosting(base, kami._BIG_TABLE_ROWS + 1)
    assert len(models) == len(base)
    for name in base:
        if name != "GradientBoostingRegressor":
            assert name in models


def test_bakeoff_explicit_model_list_is_never_swapped(capsys):
    """An explicit models=[...] must be raced exactly as asked, whatever the size."""
    kami.bakeoff(
        _div_regression_signal(),
        target="price",
        models=["GradientBoostingRegressor"],
        explain="guide",
    )
    out = capsys.readouterr().out
    assert "GradientBoostingRegressor" in out
    assert "HistGradientBoosting" not in out


# --- bakeoff must not congratulate itself for finding nothing ----------------


def _needle_in_haystack_df():
    """Lopsided classification with NO signal: the rare answer is assigned at
    random, so no model can ever predict it. Every model will score f1 = 0 by
    just answering 'no' every time - which is exactly the situation that fooled
    us on real data, dressed up as 'Winner - LogisticRegression (f1 = 0.0%)'."""
    rng = np.random.default_rng(7)
    n = 400
    return pd.DataFrame(
        {
            "feature_a": rng.normal(0, 1, n),
            "feature_b": rng.normal(0, 1, n),
            "feature_c": rng.integers(0, 5, n),
            # ~8% rare, unrelated to every feature above
            "won": np.where(rng.random(n) < 0.08, "Yes", "No"),
        }
    )


def test_bakeoff_refuses_to_name_a_winner_when_nothing_was_found(capsys):
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "NO MODEL FOUND ANYTHING" in flat
    assert "There is no winner here" in flat


def test_bakeoff_does_not_explain_why_a_zero_scoring_model_won(capsys):
    """The original bug: 'Why it likely won: it found a straight-line
    relationship in your data' printed under an f1 of 0.0%."""
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "Why it likely won" not in flat
    assert "Winner -" not in flat


def test_bakeoff_suggests_class_weight_when_nothing_was_found(capsys):
    """A dead end isn't useful without a way out of it."""
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "class_weight" in flat


def test_bakeoff_names_the_rare_answer_when_nothing_was_found(capsys):
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "'Yes'" in flat  # the rare category, quoted back to the user


def test_found_nothing_warning_replaces_the_weaker_baseline_caveat(capsys):
    """Two warnings would muddle the message - the stronger one must win."""
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "NO MODEL FOUND ANYTHING" in flat
    assert "barely beats" not in flat


def test_bakeoff_still_returns_a_usable_model_when_nothing_was_found():
    """Refusing to crown a winner must not break the return contract."""
    champ = kami.bakeoff(
        _needle_in_haystack_df(), target="won", rank_by="f1", explain="silent"
    )
    assert champ is not None
    assert hasattr(champ, "bakeoff_table")
    assert len(champ.bakeoff_table) >= 2


def test_found_nothing_warning_respects_silent_mode(capsys):
    kami.bakeoff(_needle_in_haystack_df(), target="won", rank_by="f1", explain="silent")
    assert capsys.readouterr().out.strip() == ""


def test_a_normal_race_still_names_a_winner(capsys):
    """The guard must not fire on data that genuinely has signal - otherwise
    we've traded one wrong answer for another."""
    kami.bakeoff(_classification_df(), target="signed_up", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "Winner -" in flat
    assert "NO MODEL FOUND ANYTHING" not in flat


def test_zero_r2_keeps_its_own_regression_message(capsys):
    """R2 = 0 means 'no better than the mean', not 'found nothing' - it has its
    own wording and must not be hijacked by the new guard."""
    rng = np.random.default_rng(3)
    noise = pd.DataFrame(
        {
            "a": rng.normal(0, 1, 300),
            "b": rng.normal(0, 1, 300),
            "price": rng.normal(100, 10, 300),  # unrelated to a and b
        }
    )
    kami.bakeoff(noise, target="price", rank_by="r2", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())
    assert "NO MODEL FOUND ANYTHING" not in flat


# --- champ.bakeoff_table: the race as plain data -----------------------------


def test_bakeoff_table_is_attached_to_the_winner():
    champ = kami.bakeoff(_classification_df(), target="signed_up", explain="silent")
    assert hasattr(champ, "bakeoff_table")
    assert isinstance(champ.bakeoff_table, list)
    assert len(champ.bakeoff_table) >= 2  # a race needs runners


def test_bakeoff_table_rows_are_flat_plain_dicts():
    """lana consumes plain records, so every value must be a scalar - no nested
    dicts, and above all no trained model objects riding along."""
    champ = kami.bakeoff(_classification_df(), target="signed_up", explain="silent")
    for row in champ.bakeoff_table:
        assert isinstance(row, dict)
        for key, value in row.items():
            assert isinstance(key, str)
            assert value is None or isinstance(value, (str, int, float)), (
                f"{key} holds a {type(value).__name__}, which lana can't rank"
            )


def test_bakeoff_table_has_model_time_and_interpretability():
    champ = kami.bakeoff(_classification_df(), target="signed_up", explain="silent")
    row = champ.bakeoff_table[0]
    assert isinstance(row["model"], str)
    assert row["train_seconds"] >= 0
    assert 1 <= row["interpretability"] <= 5


def test_bakeoff_table_is_ordered_best_first():
    """Row 0 must be the winner kami announced, not just any model."""
    df = _classification_df()
    champ = kami.bakeoff(df, target="signed_up", rank_by="accuracy", explain="silent")
    values = [r["accuracy"] for r in champ.bakeoff_table if r["accuracy"] is not None]
    assert values == sorted(values, reverse=True)


def test_bakeoff_table_orders_ascending_for_lower_is_better():
    champ = kami.bakeoff(
        _div_regression_signal(), target="price", rank_by="mae", explain="silent"
    )
    values = [r["mae"] for r in champ.bakeoff_table if r["mae"] is not None]
    assert values == sorted(values)  # lower MAE is better, so ascending


def test_bakeoff_table_covers_only_models_that_trained():
    champ = kami.bakeoff(
        _classification_df(),
        target="signed_up",
        models=["LogisticRegression", "RandomForestClassifier"],
        explain="silent",
    )
    names = [r["model"] for r in champ.bakeoff_table]
    assert sorted(names) == ["LogisticRegression", "RandomForestClassifier"]


def test_interpretability_ranks_linear_above_boosting():
    """The scores are judgements, but the ORDERING has to be defensible: a
    linear model you can read off as coefficients must not score below a
    gradient-boosted ensemble."""
    assert kami._bakeoff_interpretability("LogisticRegression") > \
        kami._bakeoff_interpretability("GradientBoostingClassifier")
    assert kami._bakeoff_interpretability("DecisionTreeClassifier") > \
        kami._bakeoff_interpretability("RandomForestClassifier")


def test_interpretability_of_unknown_model_is_middling():
    """An unrecognised model must be neither flattered nor punished."""
    assert kami._bakeoff_interpretability("SomeModelKamiHasNeverHeardOf") == 3


def test_bakeoff_table_feeds_lana_rank_end_to_end():
    """The whole point: kami's race becomes a lana decision with no glue code."""
    from ablevlabs import lana

    champ = kami.bakeoff(_classification_df(), target="signed_up", explain="silent")
    ranked = lana.rank(
        champ.bakeoff_table,
        priority={"accuracy": 5, "train_seconds": 2, "interpretability": 3},
        lower_better=["train_seconds"],
    )
    assert ranked["winner"]
    assert len(ranked["ranking"]) == len(champ.bakeoff_table)
    # lana's _NAME_KEYS already covers "model", so every entry is labelled with
    # its real model name rather than a generic "Item 1" - no aliases needed.
    labels = {e["name"] for e in ranked["ranking"]}
    assert labels == {r["model"] for r in champ.bakeoff_table}


def test_lana_can_reach_a_different_verdict_than_rank_by():
    """The reason this pairing earns its keep: weighting cost and readability
    alongside accuracy is ALLOWED to pick a different model than raw accuracy
    would. If it could never disagree, it would be decoration."""
    from ablevlabs import lana

    # a deliberately lopsided decision: readability matters most, accuracy least
    rows = [
        {"model": "LogisticRegression", "accuracy": 0.81, "train_seconds": 0.2,
         "interpretability": 5},
        {"model": "GradientBoosting", "accuracy": 0.84, "train_seconds": 9.0,
         "interpretability": 1},
    ]
    by_accuracy = lana.rank(rows, priority={"accuracy": 5})
    by_shipping = lana.rank(
        rows,
        priority={"accuracy": 1, "train_seconds": 4, "interpretability": 5},
        lower_better=["train_seconds"],
    )
    assert by_accuracy["winner"] == "GradientBoosting"
    assert by_shipping["winner"] == "LogisticRegression"


def test_bakeoff_teacher_mode_mentions_the_table(capsys):
    kami.bakeoff(_classification_df(), target="signed_up", explain="teacher")
    flat = " ".join(capsys.readouterr().out.split())
    assert "bakeoff_table" in flat


# --- plain-English feature importance ---------------------------------------


def test_feature_importance_text_ranks_columns(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    kami.feature_importance(result, chart=False)
    out = capsys.readouterr().out
    assert "WHICH COLUMNS MATTER MOST" in out
    assert "influence" in out
    assert "most associated with" in out


def test_feature_importance_linear_shows_direction(capsys):
    result = kami.train(
        _div_regression_signal(),
        target="price",
        model="LinearRegression",
        explain="silent",
    )
    kami.feature_importance(result, chart=False)
    out = capsys.readouterr().out
    assert "(+" in out or "(-" in out  # direction markers for linear models


# --- what-if sensitivity analysis (kami.whatif) -----------------------------


def test_whatif_numeric_sweep_returns_rows(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    rows = kami.whatif(result, {"a": 1.0, "b": 50.0}, feature="b", values=[10, 50, 100])
    out = capsys.readouterr().out
    assert "WHAT-IF" in out
    assert len(rows) == 3


def test_whatif_auto_values_for_categorical():
    result = kami.train(_div_mixed_classification(), target="churn", explain="silent")
    rows = kami.whatif(
        result,
        {"age": 50, "region": "North", "premium": True},
        feature="region",
        show=False,
    )
    # should sweep across the categories seen in training
    assert len(rows) >= 2


def test_whatif_rejects_unknown_feature():
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    with pytest.raises(ValueError, match="trained on"):
        kami.whatif(result, {"a": 1.0}, feature="nope")


# --- Phase 5 guardrails + quickplot -----------------------------------------


def _dated_regression_df(n=200, seed=4):
    rng = np.random.default_rng(seed)
    dates = pd.to_datetime("2020-01-01") + pd.to_timedelta(
        rng.integers(0, 1200, n), unit="D"
    )
    sqft = rng.uniform(800, 3000, n)
    price = sqft * 200 + dates.month * 2500 + rng.normal(0, 8000, n)
    return pd.DataFrame(
        {"sale_date": dates, "sqft": sqft.round(0), "price": price.round(0)}
    )


def test_date_columns_are_engineered_into_numeric_features():
    result = kami.train(_dated_regression_df(), target="price", explain="silent")
    assert "sale_date" in result.input_columns  # template still shows the raw date
    assert "sale_date" not in result.feature_columns  # but the model uses the parts
    for part in (
        "sale_date_year",
        "sale_date_month",
        "sale_date_day",
        "sale_date_dayofweek",
    ):
        assert part in result.feature_columns
    assert result._date_cols == ["sale_date"]


def test_predict_accepts_a_date_string_after_training():
    result = kami.train(_dated_regression_df(), target="price", explain="silent")
    preds = result.predict({"sale_date": "2021-07-15", "sqft": 2000}, show=False)
    assert len(preds) == 1


def test_find_id_like_catches_email_and_username():
    df = pd.DataFrame(
        {
            "email": [f"u{i}@x.com" for i in range(10)],
            "username": [f"name{i}" for i in range(10)],
            "age": [25, 30, 25, 40, 30, 25, 50, 40, 30, 25],  # repeats -> not id-like
        }
    )
    suspects = kami._find_id_like(df)
    assert "email" in suspects and "username" in suspects
    assert "age" not in suspects


def test_audit_flags_name_based_leakage(capsys):
    rng = np.random.default_rng(1)
    df = pd.DataFrame({"sqft": rng.uniform(800, 3000, 80)})
    df["price"] = df["sqft"] * 200
    df["sale_price"] = rng.normal(100, 5, 80)  # name looks derived from 'price'
    kami.audit(df, target="price")
    assert "name looks derived" in capsys.readouterr().out


def test_tiny_test_set_warns(capsys):
    rng = np.random.default_rng(3)
    small = pd.DataFrame({"a": rng.normal(0, 1, 60), "price": rng.normal(0, 1, 60)})
    kami.train(small, target="price", explain="guide")
    out = capsys.readouterr().out
    assert "test set" in out and "HEADS UP" in out


def test_quickplot_runs_without_error():
    rng = np.random.default_rng(2)
    df = pd.DataFrame(
        {"age": rng.integers(18, 80, 100), "region": rng.choice(["N", "S", "E"], 100)}
    )
    kami.quickplot(df)  # Agg backend (set at top of file); just shouldn't raise


def test_quickplot_needs_plottable_columns():
    df = pd.DataFrame({"d": pd.to_datetime(["2020-01-01", "2020-01-02"])})
    with pytest.raises(ValueError, match="Nothing to plot"):
        kami.quickplot(df)


# --- conclusion-first interpretation + confidence ---------------------------


def test_train_prints_conclusion_block(capsys):
    kami.train(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "WHAT KAMI FOUND" in out
    assert "Question" in out and "Answer" in out
    assert "What we learned" in out
    assert "Confidence" in out


def test_conclusion_uses_plain_english_score(capsys):
    kami.train(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "explains about" in out  # plain-English R2
    assert "[" in out and "]" in out  # the star verdict


def test_done_wording_is_encouraging(capsys):
    kami.train(_div_regression_signal(), target="price", explain="guide")
    out = capsys.readouterr().out
    assert "ready to use" in out
    assert "Done!" not in out  # old wording is gone


def test_confidence_is_stored_and_rated():
    big = kami.train(_div_regression_signal(), target="price", explain="silent")
    assert big.confidence in ("High", "Moderate", "Low")
    tiny = kami.train(
        _div_regression_signal().head(25), target="price", explain="silent"
    )
    assert tiny.confidence == "Low"  # very little data -> low confidence


def test_confidence_appears_in_summary(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    result.summary()
    assert "Confidence" in capsys.readouterr().out


def test_no_signal_conclusion_does_not_name_drivers(capsys):
    kami.train(_div_no_signal(), target="y", explain="guide")
    out = capsys.readouterr().out
    assert "do no better than guessing" in out.lower() or "don't explain" in out.lower()
    assert (
        "look most related" not in out
    )  # no misleading "top drivers" when there's no signal


def test_silent_training_prints_nothing_even_with_conclusion(capsys):
    kami.train(_div_regression_signal(), target="price", explain="silent")
    assert capsys.readouterr().out == ""


# --- Kami Academy lessons + translate ---------------------------------------


def test_learn_lesson_is_story_first(capsys):
    kami.learn("RandomForest")
    out = capsys.readouterr().out
    assert "KAMI ACADEMY" in out
    assert "Difficulty" in out
    assert "GOOD FIT WHEN" in out
    assert "kami.train(" in out  # the "try it" snippet


def test_translate_menu_lists_terms(capsys):
    kami.translate()
    out = capsys.readouterr().out
    assert "Overfitting" in out
    assert "kami.translate(" in out


def test_translate_known_term(capsys):
    kami.translate("overfitting")
    out = capsys.readouterr().out
    assert "memorised" in out or "memorized" in out
    assert "Why it matters" in out


def test_translate_accepts_aliases(capsys):
    kami.translate("R2")
    assert "R-squared" in capsys.readouterr().out
    kami.translate("data leakage")
    assert "leakage" in capsys.readouterr().out.lower()


def test_translate_unknown_term_errors():
    with pytest.raises(ValueError, match="don't have a translation"):
        kami.translate("flux capacitor")


# --- narrative charts + fold bars + suggest_charts --------------------------


def test_scatter_prints_narration(capsys):
    df = _div_regression_signal()
    kami.scatter(df, x="a", y="price")
    out = capsys.readouterr().out
    assert "What you're looking at" in out
    assert "relationship" in out


def test_chart_notes_can_be_silenced(capsys):
    df = _div_regression_signal()
    kami.scatter(df, x="a", y="price", notes=False)
    assert "What you're looking at" not in capsys.readouterr().out


def test_plot_predictions_narration(capsys):
    result = kami.train(_div_regression_signal(), target="price", explain="silent")
    kami.plot_predictions(result)
    out = capsys.readouterr().out
    assert "What you're looking at" in out
    assert "perfect prediction" in out or "dashed line" in out


def test_cross_validation_shows_fold_bars(capsys):
    kami.train(
        _div_regression_signal(), target="price", validate="cross", explain="guide"
    )
    out = capsys.readouterr().out
    assert "SCORE ON EACH FOLD" in out
    assert "Fold 1  |" in out  # the ASCII bar
    assert "#" in out


def test_suggest_charts_recommends_by_column_type(capsys):
    df = _div_mixed_classification()  # has numeric + a text column
    kami.suggest_charts(df)
    out = capsys.readouterr().out
    assert "Chart ideas" in out
    assert "kami.hist(df" in out
    assert "kami.quickplot(df)" in out


def test_suggest_charts_needs_chartable_columns():
    df = pd.DataFrame({"d": pd.to_datetime(["2020-01-01", "2020-01-02"])})
    df = df.drop(columns=["d"])  # empty frame -> nothing to chart
    with pytest.raises(ValueError, match="couldn't find"):
        kami.suggest_charts(df)


# --- Pass 5: structure, navigation, convenience -----------------------------


def test_train_ignore_drops_columns(capsys):
    df = _div_regression_signal().copy()
    df["junk"] = range(len(df))
    result = kami.train(df, target="price", ignore=["junk"], explain="silent")
    assert "junk" not in result.feature_columns


def test_train_ignore_warns_on_unknown_column(capsys):
    df = _div_regression_signal()
    kami.train(df, target="price", ignore=["not_a_column"], explain="guide")
    out = capsys.readouterr().out
    assert "Couldn't ignore" in out


def test_look_reuses_last_dataframe(capsys):
    df = _div_regression_signal()
    kami.look(df)  # remembers df
    capsys.readouterr()
    kami.look(target="price")  # no df passed -> should reuse
    out = capsys.readouterr().out
    assert "Kami is looking at your data" in out


def test_look_without_any_dataframe_is_friendly():
    kami._LAST_DF = None  # nothing remembered
    with pytest.raises(ValueError, match="don't have one remembered"):
        kami.look(target="price")


def test_explicit_none_still_hits_overwrite_trap():
    kami.look(_div_regression_signal())  # set a remembered df
    with pytest.raises(ValueError, match="empty"):
        kami.train(None, target="price")  # explicit None != omitted


def test_models_lists_recommended(capsys):
    kami.models("regression")
    out = capsys.readouterr().out
    assert "RandomForestRegressor" in out
    assert "[recommended]" in out
    assert "LogisticRegression" not in out  # filtered to regression only


def test_models_rejects_bad_task():
    with pytest.raises(ValueError, match="regression"):
        kami.models("banana")


def test_roadmap_shows_ordered_journey(capsys):
    kami.roadmap()
    out = capsys.readouterr().out
    assert "ROADMAP" in out
    assert "1. LOAD" in out
    assert "kami.train(" in out


def test_look_points_to_models_instead_of_dumping_list(capsys):
    kami.look(_div_regression_signal(), target="price")
    out = capsys.readouterr().out
    assert "kami.models(" in out  # pointer present
    assert "Recommended model:" in out


def test_bakeoff_passes_ignore_through(capsys):
    df = _div_regression_signal().copy()
    df["junk"] = range(len(df))
    champ = kami.bakeoff(df, target="price", ignore=["junk"], explain="silent")
    assert "junk" not in champ.feature_columns


# --- numeric-text handling (currency/commas) + recommend stems --------------


def _money_frame():
    rng = np.random.default_rng(5)
    n = 120
    size = rng.integers(800, 4000, n)
    price = size * 50 + rng.normal(0, 5000, n)
    return pd.DataFrame(
        {
            "sqft": size,
            "mileage": ["{:,}".format(int(v)) for v in size * 7],  # commas
            "price_usd": [
                "${:,}".format(int(p)) for p in price.clip(1000)
            ],  # $ + commas
        }
    )


def test_clean_converts_currency_and_comma_numbers(capsys):
    df = kami.clean(_money_frame())
    assert pd.api.types.is_numeric_dtype(df["mileage"])
    assert pd.api.types.is_numeric_dtype(df["price_usd"])


def test_clean_keeps_european_decimal_comma():
    df = pd.DataFrame({"weight": ["49,99", "12,50", "8,00", "100,25"]})
    out = kami.clean(df)
    assert pd.api.types.is_numeric_dtype(out["weight"])
    assert abs(float(out["weight"].iloc[0]) - 49.99) < 1e-6  # not 4999


def test_detect_task_sees_numeric_text_as_regression():
    y = pd.Series(["${:,}".format(v) for v in range(1000, 1000 + 60 * 100, 100)])
    assert kami._detect_task(y, None) == "regression"


def test_look_flags_numeric_text_target(capsys):
    kami.look(_money_frame(), target="price_usd")
    out = capsys.readouterr().out
    assert "REGRESSION" in out
    assert "numbers stored as text" in out


def test_train_coerces_numeric_text_target(capsys):
    result = kami.train(_money_frame(), target="price_usd", explain="guide")
    out = capsys.readouterr().out
    assert result.task == "regression"
    assert "stored as text" in out
    assert result.score is not None


def test_recommend_surfaces_priced_target(capsys):
    kami.recommend(_money_frame())
    out = capsys.readouterr().out
    # price_usd is a numeric-looking text column; it should rank above raw sqft
    assert out.index("price_usd") < out.index("sqft")


# --- QA-round fixes: model-family override, currency codes, near-constant, wrapping ---


def _count_target_frame():
    rng = np.random.default_rng(9)
    n = 150
    x = rng.uniform(0, 100, n)
    days = (1 + x * 0.08 + rng.normal(0, 0.5, n)).round().clip(1).astype(int)
    return pd.DataFrame({"feat_a": x, "feat_b": rng.normal(0, 1, n), "days": days})


def test_regressor_model_overrides_classification_guess():
    df = _count_target_frame()
    assert kami._detect_task(df["days"], None) == "classification"  # few distinct ints
    result = kami.train(
        df, target="days", model="GradientBoostingRegressor", explain="silent"
    )
    assert result.task == "regression"  # model family wins, no crash


def test_classifier_model_overrides_regression_guess():
    rng = np.random.default_rng(4)
    n = 240
    code = rng.integers(0, 20, n)  # 20 distinct int codes
    df = pd.DataFrame({"feat": rng.normal(0, 1, n), "code": code})
    assert kami._detect_task(df["code"], None) == "regression"  # >15 distinct ints
    result = kami.train(df, target="code", model="LogisticRegression", explain="silent")
    assert result.task == "classification"  # classifier family wins


def test_clean_strips_currency_codes():
    df = pd.DataFrame({"price": ["316,16 EUR", "108,42 EUR", "1,000 USD", "50 GBP"]})
    out = kami.clean(df)
    assert pd.api.types.is_numeric_dtype(out["price"])
    assert abs(float(out["price"].iloc[0]) - 316.16) < 1e-6  # european decimal comma
    assert abs(float(out["price"].iloc[2]) - 1000.0) < 1e-6  # thousands comma + code


def test_audit_flags_near_constant_column(capsys):
    n = 200
    status = ["OK"] * 194 + ["FAULT"] * 6  # exactly 97% one value
    rng = np.random.default_rng(3)
    df = pd.DataFrame(
        {"x": rng.normal(0, 1, n), "status": status, "y": rng.normal(0, 1, n)}
    )
    kami.audit(df, target="y")
    out = capsys.readouterr().out
    assert "status: one value covers" in out


def test_busy_train_output_stays_within_width(capsys):
    rng = np.random.default_rng(11)
    n = 200
    df = pd.DataFrame(
        {
            "many": ["cat_" + str(i % 80) for i in range(n)],  # high-card -> warning
            "noise": rng.normal(0, 1, n),
            "a": rng.normal(0, 1, n),
            "price": rng.normal(50, 5, n) + rng.uniform(0, 10, n),
        }
    )
    kami.train(df, target="price", ignore=["noise"], explain="teacher")
    out = capsys.readouterr().out
    code = ("kami.", "new_row", "result =", "champ =", "predictions =", "= kami")
    long_lines = [
        l for l in out.splitlines() if len(l) > 100 and not any(m in l for m in code)
    ]
    assert long_lines == [], long_lines


# --- next round: scaling for distance models + high-cardinality grouping -----


def test_distance_model_is_scaled_but_named_correctly():
    from sklearn.pipeline import Pipeline

    rng = np.random.default_rng(7)
    n = 200
    big, small = rng.uniform(0, 10000, n), rng.uniform(0, 1, n)
    df = pd.DataFrame(
        {"big": big, "small": small, "label": ((big / 10000 + small) > 1.0).astype(int)}
    )
    r = kami.train(df, target="label", model="KNeighborsClassifier", explain="silent")
    assert isinstance(r.raw, Pipeline)  # scaling applied
    assert r.model_name == "KNeighborsClassifier"  # but named correctly
    assert r.predict({"big": 5000, "small": 0.5}, show=False) is not None


def test_tree_model_is_not_scaled():
    from sklearn.pipeline import Pipeline

    rng = np.random.default_rng(7)
    df = pd.DataFrame(
        {
            "a": rng.normal(0, 1, 80),
            "b": rng.normal(0, 1, 80),
            "y": rng.integers(0, 2, 80),
        }
    )
    r = kami.train(df, target="y", model="RandomForestClassifier", explain="silent")
    assert not isinstance(r.raw, Pipeline)
    assert r.model_name == "RandomForestClassifier"


def test_scaling_note_shown_for_distance_model(capsys):
    rng = np.random.default_rng(1)
    df = pd.DataFrame(
        {
            "a": rng.uniform(0, 1000, 120),
            "b": rng.uniform(0, 1, 120),
            "y": rng.integers(0, 2, 120),
        }
    )
    kami.train(df, target="y", model="SVC", explain="guide")
    assert "Scaling your number columns" in capsys.readouterr().out


def _high_card_frame(n=300, distinct=120):
    rng = np.random.default_rng(13)
    cat = ["item_" + str(i % distinct) for i in range(n)]
    x = rng.normal(0, 1, n)
    return pd.DataFrame({"cat": cat, "x": x, "price": x * 5 + rng.normal(0, 1, n)})


def test_high_cardinality_grouped_to_other(capsys):
    r = kami.train(_high_card_frame(), target="price", explain="guide")
    flat = " ".join(capsys.readouterr().out.split())  # ignore line wrapping
    assert "grouped the rest as 'Other'" in flat
    assert "cat_Other" in r.feature_columns
    cat_cols = [c for c in r.feature_columns if c.startswith("cat_")]
    assert len(cat_cols) <= 31  # 120 cats must not -> 120 columns


def test_predict_unseen_category_maps_to_other():
    r = kami.train(_high_card_frame(), target="price", explain="silent")
    pred = r.predict({"cat": "totally_new_category", "x": 0.5}, show=False)
    assert pred is not None and len(pred) == 1


def test_low_cardinality_not_grouped(capsys):
    rng = np.random.default_rng(2)
    n = 200
    df = pd.DataFrame(
        {
            "color": rng.choice(["red", "blue", "green"], n),
            "x": rng.normal(0, 1, n),
            "price": rng.normal(50, 5, n),
        }
    )
    r = kami.train(df, target="price", explain="guide")
    out = capsys.readouterr().out
    assert "grouped the rest" not in out  # only 3 categories
    assert "color_Other" not in r.feature_columns


# --- chaos/robustness: messy dates, inf, null targets, large columns ---------


def test_safe_to_datetime_survives_mixed_timezones():
    # this exact input makes pd.to_datetime RAISE even with errors='coerce'
    s = pd.Series(["2022-01-01 00:00+01:00", "2022-06-01 00:00+02:00", "junk", ""])
    out = kami._safe_to_datetime(s)  # must not raise
    assert len(out) == len(s)
    assert pd.api.types.is_datetime64_any_dtype(out)


def test_clean_survives_timezone_trap_column():
    n = 60
    df = pd.DataFrame(
        {
            "ts": ["2022-01-01 00:00+01:00", "2022-06-01 00:00+02:00"] * (n // 2),
            "x": list(range(n)),
            "price": [float(i) for i in range(n)],
        }
    )
    out = kami.clean(df)  # previously crashed with ValueError
    assert isinstance(out, pd.DataFrame)
    assert len(out) <= n


def test_train_survives_inf_features_and_null_target():
    rng = np.random.default_rng(1)
    n = 200
    x = rng.normal(0, 1, n)
    feat = x * 3 + rng.normal(0, 0.5, n)
    feat[rng.choice(n, 5, replace=False)] = np.inf  # inf in a feature column
    df = pd.DataFrame({"feat": feat, "x": x, "target": x * 10 + rng.normal(0, 1, n)})
    df.loc[rng.choice(n, 10, replace=False), "target"] = np.nan  # null target rows
    r = kami.train(df, target="target", explain="silent")  # must not crash
    assert r.task == "regression" and r.score is not None


def test_train_errors_clearly_when_target_all_blank():
    df = pd.DataFrame({"a": [1, 2, 3, 4], "y": [np.nan, np.nan, np.nan, np.nan]})
    with pytest.raises(ValueError, match="nothing to learn|blank"):
        kami.train(df, target="y", explain="silent")


def test_numeric_text_fraction_handles_large_column():
    big = pd.Series(["${:,}".format(i) for i in range(20000)] + ["junk"] * 1000)
    frac = kami._numeric_text_fraction(big)  # samples internally, stays fast
    assert 0.9 < frac <= 1.0


def test_look_output_stays_within_width():
    rng = np.random.default_rng(7)
    n = 200
    df = pd.DataFrame(
        {
            "some_really_long_column_name_here": [
                "verylongvalue_" + str(i) for i in range(n)
            ],
            "many_cats": ["category_label_number_" + str(i % 60) for i in range(n)],
            "uuid_like": [f"{i:08x}-{i:04x}-abcdef0" for i in range(n)],
            "x": rng.normal(0, 1, n),
            "price": rng.normal(50, 5, n),
        }
    )
    import io
    import contextlib

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        kami.look(df, target="price")
    # check only the column-profile + advice section (the dataframe head preview is
    # rendered by pandas and is inherently wide; it isn't Kami's own prose)
    after = buf.getvalue().split("What each column holds:", 1)[-1]
    code = ("kami.", "result =", "df =")
    bad = [
        l for l in after.splitlines() if len(l) > 100 and not any(m in l for m in code)
    ]
    assert bad == [], bad
