"""
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")


# --- 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")


# --- 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
